mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
feat(integrations): add test-connection endpoints for Nightscout, Polar, Oura
This commit is contained in:
1 parent
95739369e1
commit
e5fad221a0
3 files changed
+253
No files matched your search
@@ -0,0 +1,90 @@
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getUserNightscoutCredentials } from "@/lib/nightscout/credentials";
|
||||
import { fetchSgvEntries, NightscoutApiError } from "@/lib/nightscout/client";
|
||||
import { SafeFetchError } from "@/lib/safe-fetch";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Cheap live probe — re-validate the stored URL + token by pulling a single
|
||||
// SGV entry off the user's instance via the same client the sync uses. No new
|
||||
// credentials path: it reuses `getUserNightscoutCredentials` + `fetchSgvEntries`.
|
||||
const TIMEOUT_MS = 5_000;
|
||||
|
||||
function categoriseStatus(status: number | null): {
|
||||
code: string;
|
||||
message: string;
|
||||
} {
|
||||
if (status === 401 || status === 403) {
|
||||
return {
|
||||
code: "credentials_rejected",
|
||||
message: "Nightscout rejected the token",
|
||||
};
|
||||
}
|
||||
if (status === 429) {
|
||||
return {
|
||||
code: "rate_limited",
|
||||
message: "Nightscout rate-limited the request",
|
||||
};
|
||||
}
|
||||
if (status !== null && status >= 500) {
|
||||
return { code: "upstream_error", message: "Nightscout returned an error" };
|
||||
}
|
||||
return { code: "connection_failed", message: "Nightscout connection failed" };
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "nightscout.test" } });
|
||||
|
||||
const rl = await checkRateLimit(`nightscout-test:${user.id}`, 5, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many test requests", 429, {
|
||||
errorCode: "rate_limited_self",
|
||||
});
|
||||
}
|
||||
|
||||
const creds = await getUserNightscoutCredentials(user.id);
|
||||
if (!creds) {
|
||||
return apiError("Nightscout not connected", 422, {
|
||||
errorCode: "not_configured",
|
||||
});
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
|
||||
try {
|
||||
await fetchSgvEntries({
|
||||
baseUrl: creds.baseUrl,
|
||||
token: creds.token,
|
||||
count: 1,
|
||||
allowPrivateHost: creds.allowPrivateHost,
|
||||
timeoutMs: TIMEOUT_MS,
|
||||
});
|
||||
const latencyMs = Math.round(performance.now() - start);
|
||||
return apiSuccess({ ok: true, latencyMs });
|
||||
} catch (e) {
|
||||
if (e instanceof NightscoutApiError) {
|
||||
const cat = categoriseStatus(e.status);
|
||||
annotate({
|
||||
meta: { nightscout_test_status: e.status, nightscout_test_code: cat.code },
|
||||
});
|
||||
return apiError(cat.message, 502, { errorCode: cat.code });
|
||||
}
|
||||
const isTimeout = e instanceof SafeFetchError && e.kind === "timeout";
|
||||
const isPrivate = e instanceof SafeFetchError && e.kind === "private_host";
|
||||
const code = isTimeout
|
||||
? "timeout"
|
||||
: isPrivate
|
||||
? "url_not_public"
|
||||
: "connection_failed";
|
||||
annotate({ meta: { nightscout_test_code: code } });
|
||||
return apiError(
|
||||
isTimeout ? "Nightscout request timed out" : "Nightscout connection failed",
|
||||
502,
|
||||
{ errorCode: code },
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getOuraConnection } from "@/lib/oura/credentials";
|
||||
import { safeFetch, SafeFetchError } from "@/lib/safe-fetch";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Lightweight token probe — fetch the personal-info record, the cheapest
|
||||
// authenticated Oura call that confirms the stored grant is still valid.
|
||||
// Reuses `getOuraConnection`; no new credential path.
|
||||
// Base from `src/lib/oura/client.ts` (`OURA_API_BASE`).
|
||||
const OURA_PERSONAL_INFO_URL =
|
||||
"https://api.ouraring.com/v2/usercollection/personal_info";
|
||||
const TIMEOUT_MS = 5_000;
|
||||
|
||||
function categoriseHttpStatus(status: number): { code: string; message: string } {
|
||||
if (status === 401 || status === 403) {
|
||||
return { code: "credentials_rejected", message: "Oura rejected the token" };
|
||||
}
|
||||
if (status === 429) {
|
||||
return { code: "rate_limited", message: "Oura rate-limited the request" };
|
||||
}
|
||||
if (status >= 500) {
|
||||
return { code: "upstream_error", message: "Oura returned a server error" };
|
||||
}
|
||||
return { code: "connection_failed", message: "Oura connection failed" };
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "oura.test" } });
|
||||
|
||||
const rl = await checkRateLimit(`oura-test:${user.id}`, 5, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many test requests", 429, {
|
||||
errorCode: "rate_limited_self",
|
||||
});
|
||||
}
|
||||
|
||||
const connection = await getOuraConnection(user.id);
|
||||
if (!connection) {
|
||||
return apiError("Oura not connected", 422, { errorCode: "not_configured" });
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
|
||||
try {
|
||||
const res = await safeFetch(
|
||||
OURA_PERSONAL_INFO_URL,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${connection.accessToken}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
},
|
||||
{ timeoutMs: TIMEOUT_MS },
|
||||
);
|
||||
const latencyMs = Math.round(performance.now() - start);
|
||||
|
||||
if (!res.ok) {
|
||||
const cat = categoriseHttpStatus(res.status);
|
||||
annotate({
|
||||
meta: { oura_test_status: res.status, oura_test_code: cat.code },
|
||||
});
|
||||
return apiError(cat.message, 502, { errorCode: cat.code });
|
||||
}
|
||||
|
||||
return apiSuccess({ ok: true, latencyMs });
|
||||
} catch (e) {
|
||||
const isTimeout = e instanceof SafeFetchError && e.kind === "timeout";
|
||||
const code = isTimeout ? "timeout" : "connection_failed";
|
||||
annotate({ meta: { oura_test_code: code } });
|
||||
return apiError(
|
||||
isTimeout ? "Oura request timed out" : "Oura connection failed",
|
||||
502,
|
||||
{ errorCode: code },
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getPolarConnection } from "@/lib/polar/credentials";
|
||||
import { safeFetch, SafeFetchError } from "@/lib/safe-fetch";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Lightweight token probe — fetch the registered AccessLink user record, the
|
||||
// cheapest authenticated Polar call that confirms the stored grant is still
|
||||
// valid. Reuses `getPolarConnection`; no new credential path.
|
||||
// Base from `src/lib/polar/client.ts` (`POLAR_API_BASE`).
|
||||
const POLAR_USERS_BASE = "https://www.polaraccesslink.com/v3/users";
|
||||
const TIMEOUT_MS = 5_000;
|
||||
|
||||
function categoriseHttpStatus(status: number): { code: string; message: string } {
|
||||
if (status === 401 || status === 403) {
|
||||
return { code: "credentials_rejected", message: "Polar rejected the token" };
|
||||
}
|
||||
if (status === 429) {
|
||||
return { code: "rate_limited", message: "Polar rate-limited the request" };
|
||||
}
|
||||
if (status >= 500) {
|
||||
return { code: "upstream_error", message: "Polar returned a server error" };
|
||||
}
|
||||
return { code: "connection_failed", message: "Polar connection failed" };
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "polar.test" } });
|
||||
|
||||
const rl = await checkRateLimit(`polar-test:${user.id}`, 5, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many test requests", 429, {
|
||||
errorCode: "rate_limited_self",
|
||||
});
|
||||
}
|
||||
|
||||
const connection = await getPolarConnection(user.id);
|
||||
if (!connection) {
|
||||
return apiError("Polar not connected", 422, { errorCode: "not_configured" });
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
|
||||
try {
|
||||
const res = await safeFetch(
|
||||
`${POLAR_USERS_BASE}/${encodeURIComponent(connection.polarUserId)}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${connection.accessToken}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
},
|
||||
{ timeoutMs: TIMEOUT_MS },
|
||||
);
|
||||
const latencyMs = Math.round(performance.now() - start);
|
||||
|
||||
if (!res.ok) {
|
||||
const cat = categoriseHttpStatus(res.status);
|
||||
annotate({
|
||||
meta: { polar_test_status: res.status, polar_test_code: cat.code },
|
||||
});
|
||||
return apiError(cat.message, 502, { errorCode: cat.code });
|
||||
}
|
||||
|
||||
return apiSuccess({ ok: true, latencyMs });
|
||||
} catch (e) {
|
||||
const isTimeout = e instanceof SafeFetchError && e.kind === "timeout";
|
||||
const code = isTimeout ? "timeout" : "connection_failed";
|
||||
annotate({ meta: { polar_test_code: code } });
|
||||
return apiError(
|
||||
isTimeout ? "Polar request timed out" : "Polar connection failed",
|
||||
502,
|
||||
{ errorCode: code },
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user