mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
Merge branch 'v1423/integrations' into release/v1.42.3
This commit is contained in:
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user