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