Merge branch 'v1423/integrations' into release/v1.42.3

This commit is contained in:
Marc-André Bombeck committed 2026-10-14 19:20:16 +02:00
commit 035ae37ee4
49 files changed
+2715 -27

No files matched your search

@@ -52,7 +52,12 @@ const ouraAvailable = vi.fn(async () => false);
vi.mock("@/lib/polar/credentials", () => ({
getPolarClientCredentials: () => polarAvailable(),
}));
vi.mock("@/lib/oura/credentials", () => ({
vi.mock("@/lib/oura/credentials", async () => ({
readOuraScopeDenied: (
await vi.importActual<typeof import("@/lib/oura/credentials")>(
"@/lib/oura/credentials",
)
).readOuraScopeDenied,
getOuraClientCredentials: () => ouraAvailable(),
}));
@@ -387,3 +392,55 @@ describe("/api/integrations/status — a provider with no ledger row", () => {
expect(whoop.syncHealth?.verdict).toBe("disconnected");
});
});
describe("/api/integrations/status — what a provider withholds by contract", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("names the readings the Withings plan withholds and the Oura collections the grant lacks", async () => {
(
prisma.withingsConnection.findUnique as ReturnType<typeof vi.fn>
).mockResolvedValueOnce({
tokenExpiresAt: new Date(Date.now() + 3_600_000),
lastSyncedAt: null,
createdAt: new Date(),
scope: "user.metrics,user.activity",
apiPlanState: {
version: 1,
measures: {
unavailable: [155, 54],
checkedAt: new Date().toISOString(),
},
ecg: { unavailable: ["heart"], checkedAt: new Date().toISOString() },
},
});
userFind.mockResolvedValue({
ouraAccessTokenEncrypted: "tok",
ouraScopeDenied: ["resilience", "spo2", "not_a_collection"],
});
const entries = (await fetchEntries()) as Array<
Entry & { planUnavailable?: string[]; scopeReconnect?: string[] }
>;
const withings = entries.find((e) => e.integration === "withings")!;
const oura = entries.find((e) => e.integration === "oura")!;
expect(withings.planUnavailable).toEqual(["spo2", "vascularAge", "ecg"]);
expect(oura.scopeReconnect).toEqual(["spo2", "resilience"]);
});
it("publishes empty lists when nothing is withheld", async () => {
userFind.mockResolvedValue({ ouraAccessTokenEncrypted: "tok" });
const entries = (await fetchEntries()) as Array<
Entry & { planUnavailable?: string[]; scopeReconnect?: string[] }
>;
expect(
entries.find((e) => e.integration === "withings")!.planUnavailable,
).toEqual([]);
expect(
entries.find((e) => e.integration === "oura")!.scopeReconnect,
).toEqual([]);
});
});
+30 -2
View File
@@ -35,7 +35,19 @@ import {
resolveSyncVerdict,
type SyncHealth,
} from "@/lib/integrations/sync-verdict";
import { getOuraClientCredentials } from "@/lib/oura/credentials";
import {
getOuraClientCredentials,
readOuraScopeDenied,
} from "@/lib/oura/credentials";
import {
ouraScopeReconnectList,
type OuraScopeGatedCollection,
} from "@/lib/oura/scope";
import { parseWithingsApiPlanState } from "@/lib/withings/api-plan";
import {
withingsPlanUnavailableFeatures,
type WithingsPlanFeature,
} from "@/lib/withings/plan-features";
import { getPolarClientCredentials } from "@/lib/polar/credentials";
import { getStravaClientCredentials } from "@/lib/strava/credentials";
import { hasActivityScope } from "@/lib/withings/client";
@@ -92,6 +104,7 @@ export const GET = apiHandler(async () => {
ouraAccessTokenEncrypted: true,
ouraClientIdEncrypted: true,
ouraClientSecretEncrypted: true,
ouraScopeDenied: true,
stravaAccessTokenEncrypted: true,
stravaClientIdEncrypted: true,
stravaClientSecretEncrypted: true,
@@ -109,6 +122,7 @@ export const GET = apiHandler(async () => {
lastSyncedAt: true,
createdAt: true,
scope: true,
apiPlanState: true,
},
}),
prisma.whoopConnection.findUnique({
@@ -280,6 +294,13 @@ export const GET = apiHandler(async () => {
// Null `scope` = legacy connection that predates activity-scope reads.
scope: withingsConn?.scope ?? null,
hasActivityScope: hasActivityScope(withingsConn?.scope ?? null),
// What this connection's Withings API plan withholds (status 277),
// resolved here so the card only renders it.
planUnavailable: withingsConn
? withingsPlanUnavailableFeatures(
parseWithingsApiPlanState(withingsConn.apiPlanState),
)
: [],
...resolve("withings", withingsStatus, {
connected: !!withingsConn,
configured: withingsConfigured,
@@ -374,11 +395,17 @@ export const GET = apiHandler(async () => {
hasOwnCredentials:
!!dbUser?.ouraClientIdEncrypted &&
!!dbUser?.ouraClientSecretEncrypted,
// Collections the stored grant does not cover; one reconnect asks for
// the missing scopes. Not a failure, so it never colours the pill.
scopeReconnect: ouraConnected
? ouraScopeReconnectList(readOuraScopeDenied(dbUser?.ouraScopeDenied))
: [],
...resolve("oura", ouraStatus, {
connected: ouraConnected,
configured: ouraConnected,
}),
} satisfies IntegrationViewModel & OAuthProviderExtras,
} satisfies IntegrationViewModel &
OAuthProviderExtras & { scopeReconnect: OuraScopeGatedCollection[] },
{
...publish(stravaStatus),
connected: stravaConnected,
@@ -442,6 +469,7 @@ interface WithingsExtras {
tokenExpired: boolean | null;
scope: string | null;
hasActivityScope: boolean;
planUnavailable: WithingsPlanFeature[];
}
interface WhoopExtras {
+4
View File
@@ -1,4 +1,5 @@
import { prisma } from "@/lib/db";
import { Prisma } from "@/generated/prisma/client";
import { apiHandler } from "@/lib/api-handler";
import { getSession } from "@/lib/auth/session";
import { annotate, getEvent } from "@/lib/logging/context";
@@ -87,6 +88,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
data: {
ouraAccessTokenEncrypted: encrypt(tokens.access_token),
ouraRefreshTokenEncrypted: encrypt(tokens.refresh_token),
// A new grant carries the scopes asked for now; whatever the old one
// lacked is found out again by the first sync.
ouraScopeDenied: Prisma.DbNull,
},
});
+2
View File
@@ -1,4 +1,5 @@
import { prisma } from "@/lib/db";
import { Prisma } from "@/generated/prisma/client";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiSuccess,
@@ -96,6 +97,7 @@ export const DELETE = apiHandler(async () => {
data: {
ouraAccessTokenEncrypted: null,
ouraRefreshTokenEncrypted: null,
ouraScopeDenied: Prisma.DbNull,
},
});
await clearOuraClientCredentials(user.id);
+2
View File
@@ -1,4 +1,5 @@
import { prisma } from "@/lib/db";
import { Prisma } from "@/generated/prisma/client";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError } from "@/lib/api-response";
@@ -37,6 +38,7 @@ export const POST = apiHandler(async () => {
data: {
ouraAccessTokenEncrypted: null,
ouraRefreshTokenEncrypted: null,
ouraScopeDenied: Prisma.DbNull,
},
});
+3
View File
@@ -190,6 +190,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
scope: WITHINGS_OAUTH_SCOPE,
webhookSubscriptionState: Prisma.DbNull,
webhookSubscriptionRetryAt: subscriptionRetryAt,
// A reconnect may come with another developer app (another plan), so
// what the old grant's plan refused is asked for again.
apiPlanState: Prisma.DbNull,
},
create: {
userId: user.id,