fix(google-health): read the account id back for connections that lost it

Migration 0491 cleared the shared "profile" placeholder to null. Those
connections sync normally, but the one-account-per-record check cannot see
them, so the same Google account could still be connected to a second
record until the first one reconnected.

After a clean sync cycle, a connection without an id now reads it the way
the callback does (profile, then identity) and stores it, guarded on the id
still being null so a reconnect landing meanwhile wins. An id another
record already holds is not written, and a failed read leaves the id for
the next cycle without touching the cycle's verdict. The outcome is on the
wide event as googleHealth.accountId.heal.
This commit is contained in:
Marc-André Bombeck committed 2026-10-31 09:34:25 +01:00
1 parent eb47f04a66
commit 5306ba7614
3 files changed
+198 -1

No files matched your search

+72
View File
@@ -0,0 +1,72 @@
import { prisma } from "@/lib/db";
import { annotate, getEvent } from "@/lib/logging/context";
import { accountLinkedElsewhere } from "@/lib/integrations/oauth-kit/account-link";
import { accountIdFor, fetchProfile } from "./client";
import { getValidToken, runWithGoogleHealthSyncCycle } from "./sync-core";
/** How a cycle's look at a connection without an account id ended. */
export type GoogleHealthAccountIdHeal =
"healed" | "unreadable" | "linked_elsewhere" | "failed";
/**
* Fill in the Google account id of a connection that has none.
*
* Until v1.46.4 every connection stored the constant `profile` as its id, and
* migration 0491 cleared it to null. Such a connection syncs normally, but the
* one-account-per-record check (`accountLinkedElsewhere`) cannot see it, so the
* same Google account could be connected to a second record. This reads the
* id with the connection's own token the way the callback does and stores it,
* which closes that gap without asking anyone to reconnect.
*
* Best-effort and never in the sync's way: a failed read leaves the id null
* for the next cycle, and an id another record already holds is not written
* (two records would then claim one account; the sync itself carries on, and
* the next connect on either record meets the check). The write is guarded on
* the id still being null, so a reconnect landing meanwhile wins.
*/
export async function healGoogleHealthAccountId(
userId: string,
accessToken: string,
): Promise<GoogleHealthAccountIdHeal> {
let outcome: GoogleHealthAccountIdHeal;
try {
const profile = await fetchProfile(accessToken);
const googleUserId = await accountIdFor(profile, accessToken);
if (!googleUserId) {
outcome = "unreadable";
} else if (
await accountLinkedElsewhere("google-health", googleUserId, userId)
) {
outcome = "linked_elsewhere";
} else {
await prisma.googleHealthConnection.updateMany({
where: { userId, googleUserId: null },
data: { googleUserId },
});
outcome = "healed";
}
} catch (err) {
getEvent()?.addWarning(
`google-health: account id read failed for ${userId}: ${err}`,
);
outcome = "failed";
}
annotate({ meta: { "googleHealth.accountId.heal": outcome } });
return outcome;
}
/**
* The sync cycle's hook: after a clean cycle, a connection from before
* v1.46.4 (no account id since migration 0491) reads its id back. A
* connection that has one is left alone, so the profile is read once.
*/
export async function healUnknownGoogleHealthAccountId(
userId: string,
googleUserId: string | null,
): Promise<void> {
if (googleUserId !== null) return;
const token = await runWithGoogleHealthSyncCycle(() => getValidToken(userId));
if (token.result) {
await healGoogleHealthAccountId(userId, token.result.accessToken);
}
}
+3 -1
View File
@@ -15,6 +15,7 @@ import { syncUserMetrics } from "./sync-metrics";
import { syncUserSleep } from "./sync-sleep";
import { syncUserWorkout } from "./sync-workout";
import { withGoogleHealthSyncLock } from "./sync-lock";
import { healUnknownGoogleHealthAccountId } from "./account-id-heal";
import {
GOOGLE_HEALTH_INTEGRATION_KEY,
getValidToken,
@@ -235,7 +236,7 @@ async function runGoogleHealthSyncCycle(
const cycleStartedAt = new Date();
const connection = await prisma.googleHealthConnection.findUnique({
where: { userId },
select: { lastSyncedAt: true },
select: { lastSyncedAt: true, googleUserId: true },
});
if (!connection) {
return { state: "failed", imported: 0, failed: true, resources: [] };
@@ -389,6 +390,7 @@ async function runGoogleHealthSyncCycle(
if (!failed) {
await markSynced(userId, cycleStartedAt);
await recordSyncSuccess(userId, GOOGLE_HEALTH_INTEGRATION_KEY);
await healUnknownGoogleHealthAccountId(userId, connection.googleUserId);
}
// Background-sync posture (mirrors the Fitbit tail): mark the per-user
@@ -7,6 +7,10 @@
* stored, the constant `profile`, so every connection looked like the same
* Google account and the one-account-per-record check refused the second
* record on the instance with `already_linked`, also on a reconnect.
*
* Migration 0491 cleared those placeholders to null. A connection left with
* no id syncs, but the check cannot see it; a clean sync cycle reads the id
* back with the connection's own token and stores it.
*/
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -48,6 +52,22 @@ vi.mock("@/lib/jobs/boss-ready", () => ({
enqueueWhenBossReady: vi.fn().mockResolvedValue("skipped"),
}));
// The sync cycle's four resources stand in as one written row each, so a
// cycle is clean and reaches the account-id read; their own wire is covered
// elsewhere.
vi.mock("@/lib/google-health/sync-workout", () => ({
syncUserWorkout: vi.fn(async () => 1),
}));
vi.mock("@/lib/google-health/sync-sleep", () => ({
syncUserSleep: vi.fn(async () => 1),
}));
vi.mock("@/lib/google-health/sync-activity", () => ({
syncUserActivity: vi.fn(async () => 1),
}));
vi.mock("@/lib/google-health/sync-metrics", () => ({
syncUserMetrics: vi.fn(async () => 1),
}));
const TOKEN_URL = "https://oauth2.googleapis.com/token";
const PROFILE_URL = "https://health.googleapis.com/v4/users/me/profile";
const IDENTITY_URL = "https://health.googleapis.com/v4/users/me/identity";
@@ -214,3 +234,106 @@ describe("Google Health account id", () => {
expect(await storedId(SECOND)).toBeNull();
});
});
/** A connection as migration 0491 leaves it: synced, token valid, id unknown. */
async function seedUnknownId(
userId: string,
googleUserId: string | null = null,
) {
const { encrypt } = await import("@/lib/crypto");
await getPrismaClient().googleHealthConnection.create({
data: {
userId,
googleUserId,
accessToken: encrypt(`access-${userId}`),
refreshToken: encrypt(`refresh-${userId}`),
tokenExpiresAt: new Date(Date.now() + 3_600_000),
needsReauth: false,
},
});
}
function profileReads(): number {
return vi
.mocked(globalThis.fetch)
.mock.calls.filter(([input]) => urlOf(input) === PROFILE_URL).length;
}
describe("Google Health account id after the placeholder repair", () => {
it("reads the id back on a clean sync, so the same account is refused elsewhere", async () => {
await seedUnknownId(FIRST);
googleAs("111");
const { syncUserGoogleHealth } = await import("@/lib/google-health/sync");
const result = await syncUserGoogleHealth(FIRST);
expect(result.failed).toBe(false);
expect(await storedId(FIRST)).toBe("111");
expect(await connect(SECOND)).toContain("reason=already_linked");
expect(await storedId(SECOND)).toBeUndefined();
});
it("does not read the profile again once the id is known", async () => {
await seedUnknownId(FIRST, "111");
googleAs("111");
const { syncUserGoogleHealth } = await import("@/lib/google-health/sync");
await syncUserGoogleHealth(FIRST);
expect(profileReads()).toBe(0);
expect(await storedId(FIRST)).toBe("111");
});
it("leaves the id unknown when another record already holds the account", async () => {
await seedUnknownId(FIRST);
await seedUnknownId(SECOND, "111");
googleAs("111");
const { healGoogleHealthAccountId } =
await import("@/lib/google-health/account-id-heal");
expect(await healGoogleHealthAccountId(FIRST, "access-gh-first")).toBe(
"linked_elsewhere",
);
expect(await storedId(FIRST)).toBeNull();
expect(await storedId(SECOND)).toBe("111");
});
it("keeps an id a reconnect stored while the read was under way", async () => {
await seedUnknownId(FIRST);
vi.mocked(globalThis.fetch).mockImplementation(async (input) => {
const url = urlOf(input);
if (url === PROFILE_URL) {
// The callback lands between the profile read and the write.
await getPrismaClient().googleHealthConnection.update({
where: { userId: FIRST },
data: { googleUserId: "999" },
});
return json({ name: "users/111/profile" });
}
throw new Error(`Unexpected fetch: ${url}`);
});
const { healGoogleHealthAccountId } =
await import("@/lib/google-health/account-id-heal");
expect(await healGoogleHealthAccountId(FIRST, "access-gh-first")).toBe(
"healed",
);
expect(await storedId(FIRST)).toBe("999");
});
it("stays unknown, and the sync stays clean, when Google names no account", async () => {
await seedUnknownId(FIRST);
googleAs("444", { profileName: "users/me/profile", identityStatus: 403 });
const { syncUserGoogleHealth } = await import("@/lib/google-health/sync");
const result = await syncUserGoogleHealth(FIRST);
expect(result.failed).toBe(false);
expect(await storedId(FIRST)).toBeNull();
});
it("keeps the sync clean when the profile read fails", async () => {
await seedUnknownId(FIRST);
vi.mocked(globalThis.fetch).mockImplementation(async (input) => {
if (urlOf(input) === PROFILE_URL) return json({ error: {} }, 503);
throw new Error(`Unexpected fetch: ${urlOf(input)}`);
});
const { syncUserGoogleHealth } = await import("@/lib/google-health/sync");
const result = await syncUserGoogleHealth(FIRST);
expect(result.failed).toBe(false);
expect(await storedId(FIRST)).toBeNull();
});
});