mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
feat(integrations): unify OAuth returns and the status envelope
The WHOOP and Fitbit OAuth callbacks redirect back with `?whoop=connected|error` / `?fitbit=connected|error`, but the settings page only read that param for Withings, Polar and Oura — a user returning from a WHOOP or Fitbit round-trip landed on a silently unchanged page with no toast. Extend the generic return handler to all four OAuth providers, add the matching connected/failed/error i18n keys across every locale, and invalidate both the per-card key and the consolidated envelope on a successful return. Polar and Oura already write the shared IntegrationKey ledger but each fired its own `/api/<provider>/status` round-trip from the card, so the page issued one consolidated request plus three per-card ones. Fold Polar and Oura into `/api/integrations/status` and let the cards read a passed view-model instead of fetching, retiring the extra round-trips. The per-provider status routes stay for the iOS and test callers.
This commit is contained in:
1 parent
38a2c7127e
commit
9430e89889
15 files changed
+583
-42
No files matched your search
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
withingsConnection: { findUnique: vi.fn(async () => null) },
|
||||
whoopConnection: { findUnique: vi.fn(async () => null) },
|
||||
fitbitConnection: { findUnique: vi.fn(async () => null) },
|
||||
moodEntry: { count: vi.fn(async () => 0) },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/api-response", () => ({
|
||||
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
|
||||
}));
|
||||
|
||||
const ledger: Record<string, unknown> = {
|
||||
state: "connected",
|
||||
lastSuccessAt: null,
|
||||
lastAttemptAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
vi.mock("@/lib/integrations/status", () => ({
|
||||
getIntegrationStatus: vi.fn(async (_u: string, integration: string) => ({
|
||||
integration,
|
||||
...ledger,
|
||||
})),
|
||||
getPersistentFailureThreshold: () => 5,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/withings/client", () => ({ hasActivityScope: () => false }));
|
||||
vi.mock("@/lib/moodlog-secret", () => ({ readMoodLogSecret: () => null }));
|
||||
|
||||
const polarAvailable = vi.fn(async () => true);
|
||||
const ouraAvailable = vi.fn(async () => false);
|
||||
vi.mock("@/lib/polar/credentials", () => ({
|
||||
getPolarClientCredentials: () => polarAvailable(),
|
||||
}));
|
||||
vi.mock("@/lib/oura/credentials", () => ({
|
||||
getOuraClientCredentials: () => ouraAvailable(),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const userFind = prisma.user.findUnique as ReturnType<typeof vi.fn>;
|
||||
|
||||
type Entry = {
|
||||
integration: string;
|
||||
connected?: boolean;
|
||||
configured?: boolean;
|
||||
available?: boolean;
|
||||
hasOwnCredentials?: boolean;
|
||||
};
|
||||
|
||||
async function fetchEntries(): Promise<Entry[]> {
|
||||
const res = (await (GET as unknown as () => Promise<{ data: unknown }>)())
|
||||
.data as { integrations: Entry[] };
|
||||
return res.integrations;
|
||||
}
|
||||
|
||||
describe("/api/integrations/status — Polar/Oura fold (04-M2)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
polarAvailable.mockResolvedValue(true);
|
||||
ouraAvailable.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
it("includes polar + oura entries on the consolidated envelope", async () => {
|
||||
userFind.mockResolvedValue({
|
||||
polarAccessTokenEncrypted: "tok",
|
||||
polarClientIdEncrypted: "id",
|
||||
polarClientSecretEncrypted: "sec",
|
||||
ouraAccessTokenEncrypted: null,
|
||||
ouraClientIdEncrypted: null,
|
||||
ouraClientSecretEncrypted: null,
|
||||
});
|
||||
|
||||
const entries = await fetchEntries();
|
||||
const keys = entries.map((e) => e.integration);
|
||||
expect(keys).toEqual(
|
||||
expect.arrayContaining(["polar", "oura"]),
|
||||
);
|
||||
|
||||
const polar = entries.find((e) => e.integration === "polar")!;
|
||||
expect(polar.connected).toBe(true);
|
||||
expect(polar.configured).toBe(true);
|
||||
expect(polar.available).toBe(true);
|
||||
expect(polar.hasOwnCredentials).toBe(true);
|
||||
|
||||
const oura = entries.find((e) => e.integration === "oura")!;
|
||||
expect(oura.connected).toBe(false);
|
||||
expect(oura.available).toBe(false);
|
||||
expect(oura.hasOwnCredentials).toBe(false);
|
||||
});
|
||||
|
||||
it("reports polar disconnected when no access token is stored", async () => {
|
||||
userFind.mockResolvedValue({
|
||||
polarAccessTokenEncrypted: null,
|
||||
polarClientIdEncrypted: null,
|
||||
polarClientSecretEncrypted: null,
|
||||
});
|
||||
const entries = await fetchEntries();
|
||||
const polar = entries.find((e) => e.integration === "polar")!;
|
||||
expect(polar.connected).toBe(false);
|
||||
expect(polar.configured).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
getPersistentFailureThreshold,
|
||||
type IntegrationKey,
|
||||
} from "@/lib/integrations/status";
|
||||
import { getOuraClientCredentials } from "@/lib/oura/credentials";
|
||||
import { getPolarClientCredentials } from "@/lib/polar/credentials";
|
||||
import { hasActivityScope } from "@/lib/withings/client";
|
||||
import { readMoodLogSecret } from "@/lib/moodlog-secret";
|
||||
|
||||
@@ -38,16 +40,25 @@ export const GET = apiHandler(async () => {
|
||||
moodLogStatus,
|
||||
whoopStatus,
|
||||
fitbitStatus,
|
||||
polarStatus,
|
||||
ouraStatus,
|
||||
dbUser,
|
||||
withingsConn,
|
||||
whoopConn,
|
||||
fitbitConn,
|
||||
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,
|
||||
] = await Promise.all([
|
||||
getIntegrationStatus(user.id, "withings"),
|
||||
getIntegrationStatus(user.id, "moodlog"),
|
||||
getIntegrationStatus(user.id, "whoop"),
|
||||
getIntegrationStatus(user.id, "fitbit"),
|
||||
getIntegrationStatus(user.id, "polar"),
|
||||
getIntegrationStatus(user.id, "oura"),
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
@@ -57,6 +68,12 @@ export const GET = apiHandler(async () => {
|
||||
whoopClientSecretEncrypted: true,
|
||||
fitbitClientIdEncrypted: true,
|
||||
fitbitClientSecretEncrypted: true,
|
||||
polarAccessTokenEncrypted: true,
|
||||
polarClientIdEncrypted: true,
|
||||
polarClientSecretEncrypted: true,
|
||||
ouraAccessTokenEncrypted: true,
|
||||
ouraClientIdEncrypted: true,
|
||||
ouraClientSecretEncrypted: true,
|
||||
moodLogUrlEncrypted: true,
|
||||
moodLogApiKeyEncrypted: true,
|
||||
moodLogEnabled: true,
|
||||
@@ -95,6 +112,8 @@ export const GET = apiHandler(async () => {
|
||||
prisma.moodEntry.count({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
}),
|
||||
getPolarClientCredentials(user.id).then((c) => !!c),
|
||||
getOuraClientCredentials(user.id).then((c) => !!c),
|
||||
]);
|
||||
|
||||
const now = Date.now();
|
||||
@@ -164,6 +183,28 @@ export const GET = apiHandler(async () => {
|
||||
: null,
|
||||
backfillCompleted: fitbitConn ? !!fitbitConn.backfillCompletedAt : null,
|
||||
} satisfies IntegrationViewModel & FitbitExtras,
|
||||
{
|
||||
...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: !!dbUser?.polarAccessTokenEncrypted,
|
||||
configured: !!dbUser?.polarAccessTokenEncrypted,
|
||||
available: polarAvailable,
|
||||
hasOwnCredentials:
|
||||
!!dbUser?.polarClientIdEncrypted &&
|
||||
!!dbUser?.polarClientSecretEncrypted,
|
||||
} satisfies IntegrationViewModel & OAuthProviderExtras,
|
||||
{
|
||||
...ouraStatus,
|
||||
connected: !!dbUser?.ouraAccessTokenEncrypted,
|
||||
configured: !!dbUser?.ouraAccessTokenEncrypted,
|
||||
available: ouraAvailable,
|
||||
hasOwnCredentials:
|
||||
!!dbUser?.ouraClientIdEncrypted &&
|
||||
!!dbUser?.ouraClientSecretEncrypted,
|
||||
} satisfies IntegrationViewModel & OAuthProviderExtras,
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -214,3 +255,13 @@ interface FitbitExtras {
|
||||
tokenExpired: boolean | null;
|
||||
backfillCompleted: boolean | null;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
Reference in new issue
Block a user