Files
HealthLog/src/app/api/integrations/status/route.ts
T
Marc-André Bombeck de47bfa5ba feat(integrations): resolve sync health on the server
The ledger records what the last attempt did. It has no way to say what is
no longer being attempted: a row frozen at `error_transient` for a month
reads exactly like one retrying every hour, because nothing anywhere read
the age of `lastAttemptAt`.

`sync-verdict.ts` derives the missing reading as a pure read-side
projection: eight verdicts down one ladder, no writer, no cron, nothing that
can desynchronise from the row it reads. `stalled` is the new one, and it
deliberately outranks `failing` — an integration nobody is calling is not
failing, it is not running.

Every entry on `/api/integrations/status` carries the verdict, computed
inline from data the route already holds. That placement is the point: a
card-level verdict misses a card-less provider by construction, which is how
a dead sync stayed invisible. Nightscout joins the envelope so its card can
stop fetching its own status.

The freshness module gains the workout leg (Strava writes no measurements at
all) and a source-parameterised core so the Apple Health route can reuse it.
Its per-metric `stale` flag fires only while the provider itself reads
healthy: that combination is the dead-pipe signature, and when the whole
connection has stopped every metric ages together anyway.

MCP reports the verdict beside `connected`, which only ever meant "not
explicitly disconnected".
2026-07-25 09:41:20 +02:00

508 lines
19 KiB
TypeScript

/**
* Combined status endpoint for the Settings → Integrations card.
*
* Returns a snapshot per integration containing:
* - the IntegrationStatus row (state, last-success, last-attempt,
* decrypted last-error, per-kind failure buckets, threshold)
* - integration-specific extras the UI already shows (Withings:
* credentials configured, token expiry, OAuth-connected; moodLog:
* credentials configured, enabled flag)
*
* This is the single fetch the Settings → Integrations cards read off
* — it carries every field the four cards render (Withings activity
* scope, WHOOP/Fitbit backfill state, moodLog webhook secret + entry
* count) so the per-card /api/<provider>/status round-trips are gone
* from the web. The legacy per-provider routes stay for the iOS/test
* callers; the web cards no longer hit them.
*/
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import {
getIntegrationStatus,
getPersistentFailureThreshold,
type IntegrationKey,
} from "@/lib/integrations/status";
import {
getSourceMetricFreshness,
type MetricFreshnessEntry,
type MetricFreshnessSample,
} from "@/lib/integrations/metric-freshness";
import {
classifyMetricFreshness,
INTEGRATION_CADENCE,
resolveSyncVerdict,
type SyncHealth,
} from "@/lib/integrations/sync-verdict";
import { getOuraClientCredentials } from "@/lib/oura/credentials";
import { getPolarClientCredentials } from "@/lib/polar/credentials";
import { getStravaClientCredentials } from "@/lib/strava/credentials";
import { hasActivityScope } from "@/lib/withings/client";
import { readMoodLogSecret } from "@/lib/moodlog-secret";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "integrations.status" } });
const [
withingsStatus,
moodLogStatus,
whoopStatus,
fitbitStatus,
googleHealthStatus,
polarStatus,
ouraStatus,
stravaStatus,
nightscoutStatus,
dbUser,
withingsConn,
whoopConn,
fitbitConn,
googleHealthConn,
moodLogEntryCount,
// v1.17.1 — `available` reports whether usable OAuth credentials resolve
// (per-user BYO first, then the shared env app), mirroring the per-card
// /api/<provider>/status the consolidated envelope now replaces.
polarAvailable,
ouraAvailable,
stravaAvailable,
] = await Promise.all([
getIntegrationStatus(user.id, "withings"),
getIntegrationStatus(user.id, "moodlog"),
getIntegrationStatus(user.id, "whoop"),
getIntegrationStatus(user.id, "fitbit"),
getIntegrationStatus(user.id, "google-health"),
getIntegrationStatus(user.id, "polar"),
getIntegrationStatus(user.id, "oura"),
getIntegrationStatus(user.id, "strava"),
getIntegrationStatus(user.id, "nightscout"),
prisma.user.findUnique({
where: { id: user.id },
select: {
withingsClientIdEncrypted: true,
withingsClientSecretEncrypted: true,
whoopClientIdEncrypted: true,
whoopClientSecretEncrypted: true,
fitbitClientIdEncrypted: true,
fitbitClientSecretEncrypted: true,
googleHealthClientIdEncrypted: true,
googleHealthClientSecretEncrypted: true,
polarAccessTokenEncrypted: true,
polarClientIdEncrypted: true,
polarClientSecretEncrypted: true,
ouraAccessTokenEncrypted: true,
ouraClientIdEncrypted: true,
ouraClientSecretEncrypted: true,
stravaAccessTokenEncrypted: true,
stravaClientIdEncrypted: true,
stravaClientSecretEncrypted: true,
moodLogUrlEncrypted: true,
moodLogApiKeyEncrypted: true,
moodLogEnabled: true,
moodLogLastSyncedAt: true,
moodLogWebhookSecret: true,
// Nightscout folds onto the envelope; the card reads these three off
// it instead of its own /api/nightscout/status round-trip.
nightscoutUrlEncrypted: true,
nightscoutTokenEncrypted: true,
nightscoutAllowPrivateHost: true,
},
}),
prisma.withingsConnection.findUnique({
where: { userId: user.id },
select: {
tokenExpiresAt: true,
lastSyncedAt: true,
createdAt: true,
scope: true,
},
}),
prisma.whoopConnection.findUnique({
where: { userId: user.id },
select: {
tokenExpiresAt: true,
whoopUserId: true,
lastSyncedAt: true,
createdAt: true,
backfillCompletedAt: true,
},
}),
prisma.fitbitConnection.findUnique({
where: { userId: user.id },
select: {
tokenExpiresAt: true,
lastSyncedAt: true,
createdAt: true,
backfillCompletedAt: true,
},
}),
prisma.googleHealthConnection.findUnique({
where: { userId: user.id },
select: {
tokenExpiresAt: true,
lastSyncedAt: true,
createdAt: true,
backfillCompletedAt: true,
needsReauth: true,
},
}),
// v1.7.0 sync — exclude tombstoned rows from the entry count.
prisma.moodEntry.count({
where: { userId: user.id, deletedAt: null },
}),
getPolarClientCredentials(user.id).then((c) => !!c),
getOuraClientCredentials(user.id).then((c) => !!c),
getStravaClientCredentials(user.id).then((c) => !!c),
]);
// F-SYNC-1 — per-metric-type last-value timestamps so the card can show that a
// single metric type has gone silent even while the integration reads green
// ("connected · 5 min ago"). Fail-soft: this is an honesty signal, never worth
// 500-ing the whole Settings card, so a groupBy hiccup degrades to no data.
const metricFreshness = await getSourceMetricFreshness(user.id).catch(
() => ({}) as Partial<Record<IntegrationKey, MetricFreshnessSample[]>>,
);
const now = Date.now();
const whoopConnected = !!whoopConn?.whoopUserId;
/**
* The sync-health verdict + the per-metric staleness flags, resolved from
* data already in hand — no additional query. Every entry carries it,
* whether or not a card exists for the provider: a card-level verdict misses
* a card-less provider by construction, which is how a month-dead sync went
* unnoticed.
*/
function resolve(
integration: IntegrationKey,
snapshot: {
state: string;
lastSuccessAt: string | null;
lastAttemptAt: string | null;
},
opts: {
connected?: boolean;
configured?: boolean;
legacyLastSyncedAt?: string | null;
},
): { syncHealth: SyncHealth; metricFreshness: MetricFreshnessEntry[] } {
const syncHealth = resolveSyncVerdict({
connected: opts.connected,
configured: opts.configured,
state: snapshot.state,
lastSuccessAt: snapshot.lastSuccessAt,
lastAttemptAt: snapshot.lastAttemptAt,
legacyLastSyncedAt: opts.legacyLastSyncedAt ?? null,
cadence: INTEGRATION_CADENCE[integration],
now,
});
return {
syncHealth,
metricFreshness: classifyMetricFreshness(
metricFreshness[integration] ?? [],
syncHealth.verdict,
now,
),
};
}
const withingsConfigured =
!!dbUser?.withingsClientIdEncrypted &&
!!dbUser?.withingsClientSecretEncrypted;
const withingsLegacyLastSyncedAt =
withingsConn?.lastSyncedAt?.toISOString() ?? null;
const moodLogConfigured = !!dbUser?.moodLogUrlEncrypted;
const moodLogLegacyLastSyncedAt =
dbUser?.moodLogLastSyncedAt?.toISOString() ?? null;
const whoopConfigured =
!!dbUser?.whoopClientIdEncrypted && !!dbUser?.whoopClientSecretEncrypted;
const whoopLegacyLastSyncedAt = whoopConnected
? (whoopConn?.lastSyncedAt?.toISOString() ?? null)
: null;
const fitbitConnected = !!fitbitConn;
const fitbitConfigured =
!!dbUser?.fitbitClientIdEncrypted && !!dbUser?.fitbitClientSecretEncrypted;
const fitbitLegacyLastSyncedAt =
fitbitConn?.lastSyncedAt?.toISOString() ?? null;
const googleHealthConnected = !!googleHealthConn;
const googleHealthConfigured =
!!dbUser?.googleHealthClientIdEncrypted &&
!!dbUser?.googleHealthClientSecretEncrypted;
const googleHealthLegacyLastSyncedAt =
googleHealthConn?.lastSyncedAt?.toISOString() ?? null;
const polarConnected = !!dbUser?.polarAccessTokenEncrypted;
const ouraConnected = !!dbUser?.ouraAccessTokenEncrypted;
const stravaConnected = !!dbUser?.stravaAccessTokenEncrypted;
const nightscoutConfigured = !!dbUser?.nightscoutUrlEncrypted;
return apiSuccess({
threshold: getPersistentFailureThreshold(),
integrations: [
{
...withingsStatus,
configured: withingsConfigured,
connected: !!withingsConn,
connectedAt: withingsConn?.createdAt?.toISOString() ?? null,
// Surface the connection row's success time too so the UI
// can fall back to it when no IntegrationStatus row exists
// yet (legacy connections established before this feature).
legacyLastSyncedAt: withingsLegacyLastSyncedAt,
tokenExpiresAt: withingsConn?.tokenExpiresAt?.toISOString() ?? null,
tokenExpired: withingsConn
? withingsConn.tokenExpiresAt.getTime() <= now
: null,
// v1.4.25 W5d — `scope` is the comma-separated OAuth scope string;
// `hasActivityScope` is the derived flag the reconnect banner reads.
// Null `scope` = legacy connection that predates activity-scope reads.
scope: withingsConn?.scope ?? null,
hasActivityScope: hasActivityScope(withingsConn?.scope ?? null),
...resolve("withings", withingsStatus, {
connected: !!withingsConn,
configured: withingsConfigured,
legacyLastSyncedAt: withingsLegacyLastSyncedAt,
}),
} satisfies IntegrationViewModel & WithingsExtras,
{
...moodLogStatus,
// moodLog "configured" tracks the URL alone (the API key is
// optional for the webhook-only path), matching the legacy
// /api/integrations/moodlog/status contract the card relied on.
configured: moodLogConfigured,
enabled: dbUser?.moodLogEnabled ?? false,
legacyLastSyncedAt: moodLogLegacyLastSyncedAt,
// V3 audit STILL-V2-C-2: stored secret is AES-GCM encrypted at rest;
// decrypt for the settings page (legacy plaintext is also handled).
webhookSecret: readMoodLogSecret(dbUser?.moodLogWebhookSecret ?? null),
entryCount: moodLogEntryCount,
// moodLog has no connection row — `configured` alone decides whether it
// is meant to be running, so the verdict gets no `connected` hint.
...resolve("moodlog", moodLogStatus, {
configured: moodLogConfigured,
legacyLastSyncedAt: moodLogLegacyLastSyncedAt,
}),
} satisfies IntegrationViewModel & MoodLogExtras,
{
...whoopStatus,
configured: whoopConfigured,
connected: whoopConnected,
connectedAt: whoopConnected
? (whoopConn?.createdAt.toISOString() ?? null)
: null,
legacyLastSyncedAt: whoopLegacyLastSyncedAt,
tokenExpiresAt: whoopConnected
? (whoopConn?.tokenExpiresAt.toISOString() ?? null)
: null,
tokenExpired: whoopConnected
? (whoopConn?.tokenExpiresAt.getTime() ?? 0) <= now
: null,
backfillCompleted: whoopConnected
? !!whoopConn?.backfillCompletedAt
: null,
...resolve("whoop", whoopStatus, {
connected: whoopConnected,
configured: whoopConfigured,
legacyLastSyncedAt: whoopLegacyLastSyncedAt,
}),
} satisfies IntegrationViewModel & WhoopExtras,
{
...fitbitStatus,
configured: fitbitConfigured,
connected: fitbitConnected,
connectedAt: fitbitConn?.createdAt?.toISOString() ?? null,
legacyLastSyncedAt: fitbitLegacyLastSyncedAt,
tokenExpiresAt: fitbitConn?.tokenExpiresAt?.toISOString() ?? null,
tokenExpired: fitbitConn
? fitbitConn.tokenExpiresAt.getTime() <= now
: null,
backfillCompleted: fitbitConn ? !!fitbitConn.backfillCompletedAt : null,
...resolve("fitbit", fitbitStatus, {
connected: fitbitConnected,
configured: fitbitConfigured,
legacyLastSyncedAt: fitbitLegacyLastSyncedAt,
}),
} satisfies IntegrationViewModel & FitbitExtras,
{
...googleHealthStatus,
configured: googleHealthConfigured,
connected: googleHealthConnected,
connectedAt: googleHealthConn?.createdAt?.toISOString() ?? null,
legacyLastSyncedAt: googleHealthLegacyLastSyncedAt,
tokenExpiresAt: googleHealthConn?.tokenExpiresAt?.toISOString() ?? null,
tokenExpired: googleHealthConn
? googleHealthConn.tokenExpiresAt.getTime() <= now
: null,
backfillCompleted: googleHealthConn
? !!googleHealthConn.backfillCompletedAt
: null,
// v1.27.0 — `needsReauth` is the 7-day Testing-mode refresh expiry (or a
// user-revoked grant) surfaced from the connection row; the card reads it
// to raise a distinct "Reconnect" CTA separate from parked/disconnected.
needsReauth: googleHealthConn ? googleHealthConn.needsReauth : false,
...resolve("google-health", googleHealthStatus, {
connected: googleHealthConnected,
configured: googleHealthConfigured,
legacyLastSyncedAt: googleHealthLegacyLastSyncedAt,
}),
} satisfies IntegrationViewModel & GoogleHealthExtras,
{
...polarStatus,
// `connected` = a stored access token; `configured` mirrors it (the
// OAuth card has no separate "credentials saved but disconnected" view
// beyond `hasOwnCredentials`). The card greys out the connect button
// when no usable credentials resolve (`available`).
connected: polarConnected,
configured: polarConnected,
available: polarAvailable,
hasOwnCredentials:
!!dbUser?.polarClientIdEncrypted &&
!!dbUser?.polarClientSecretEncrypted,
...resolve("polar", polarStatus, {
connected: polarConnected,
configured: polarConnected,
}),
} satisfies IntegrationViewModel & OAuthProviderExtras,
{
...ouraStatus,
connected: ouraConnected,
configured: ouraConnected,
available: ouraAvailable,
hasOwnCredentials:
!!dbUser?.ouraClientIdEncrypted &&
!!dbUser?.ouraClientSecretEncrypted,
...resolve("oura", ouraStatus, {
connected: ouraConnected,
configured: ouraConnected,
}),
} satisfies IntegrationViewModel & OAuthProviderExtras,
{
...stravaStatus,
connected: stravaConnected,
configured: stravaConnected,
available: stravaAvailable,
hasOwnCredentials:
!!dbUser?.stravaClientIdEncrypted &&
!!dbUser?.stravaClientSecretEncrypted,
// Strava writes Workout rows, not Measurements — its freshness list
// carries the `WORKOUTS` pseudo-entry alone.
...resolve("strava", stravaStatus, {
connected: stravaConnected,
configured: stravaConnected,
}),
} satisfies IntegrationViewModel & OAuthProviderExtras,
{
...nightscoutStatus,
// Nightscout is a URL the self-hoster pastes once; `configured` (a
// stored URL) is the connect marker, and a fully-public instance has
// no token. The card reads these off the envelope now instead of its
// own status round-trip, so the whole page reads from one source.
connected: nightscoutConfigured,
configured: nightscoutConfigured,
hasToken: !!dbUser?.nightscoutTokenEncrypted,
allowPrivateHost: dbUser?.nightscoutAllowPrivateHost ?? false,
...resolve("nightscout", nightscoutStatus, {
connected: nightscoutConfigured,
configured: nightscoutConfigured,
}),
} satisfies IntegrationViewModel & NightscoutExtras,
],
});
});
interface IntegrationViewModel {
integration: IntegrationKey;
state: string;
lastSuccessAt: string | null;
lastAttemptAt: string | null;
lastError: string | null;
/**
* The server-resolved liveness verdict. Present on EVERY entry, whether or
* not a card renders it — the unit of coverage is the envelope entry, not
* the card.
*/
syncHealth: SyncHealth;
/**
* Per-metric-type last-value timestamps for the integration's synced data,
* each with the server-computed `stale` flag. Empty for moodLog (which
* writes MoodEntry rows, not Measurements). Lets the card flag a silently
* dead metric the green integration state hides.
*/
metricFreshness: MetricFreshnessEntry[];
}
interface WithingsExtras {
configured: boolean;
connected: boolean;
connectedAt: string | null;
legacyLastSyncedAt: string | null;
tokenExpiresAt: string | null;
tokenExpired: boolean | null;
scope: string | null;
hasActivityScope: boolean;
}
interface MoodLogExtras {
configured: boolean;
enabled: boolean;
legacyLastSyncedAt: string | null;
webhookSecret: string | null;
entryCount: number;
}
interface WhoopExtras {
configured: boolean;
connected: boolean;
connectedAt: string | null;
legacyLastSyncedAt: string | null;
tokenExpiresAt: string | null;
tokenExpired: boolean | null;
backfillCompleted: boolean | null;
}
interface FitbitExtras {
configured: boolean;
connected: boolean;
connectedAt: string | null;
legacyLastSyncedAt: string | null;
tokenExpiresAt: string | null;
tokenExpired: boolean | null;
backfillCompleted: boolean | null;
}
// v1.27.0 — Google Health mirrors the Fitbit shape plus `needsReauth`: Google
// expires the refresh token after 7 days in "Testing" publishing mode, so a
// connected user is periodically pushed back through OAuth. The card reads the
// flag to raise a distinct reconnect banner.
interface GoogleHealthExtras {
configured: boolean;
connected: boolean;
connectedAt: string | null;
legacyLastSyncedAt: string | null;
tokenExpiresAt: string | null;
tokenExpired: boolean | null;
backfillCompleted: boolean | null;
needsReauth: boolean;
}
// v1.17.1 — Polar / Oura fold into the consolidated envelope. They carry the
// per-user BYO-key flags the shared OAuth card reads instead of the dedicated
// /api/<provider>/status round-trip the page used to fire per card.
interface OAuthProviderExtras {
connected: boolean;
configured: boolean;
available: boolean;
hasOwnCredentials: boolean;
}
// Nightscout folds onto the envelope so the card stops firing its own status
// round-trip and so the provider inherits the shared verdict. `hasToken` and
// `allowPrivateHost` are the two fields the card's form placeholders read.
interface NightscoutExtras {
connected: boolean;
configured: boolean;
hasToken: boolean;
allowPrivateHost: boolean;
}