mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
Add Strava as a per-user BYO-OAuth workout source, mirroring the shipped WHOOP/Oura/Polar pattern. Strava caps every new app at single-player mode, so each self-hoster registers their own app and pastes the client id/secret into Settings (falling back to a shared env app). Strava is a workout source only — it exposes no daily metrics and no training-depth analytics are derived. - Schema (migration 0233): per-user encrypted client id/secret + access/refresh tokens, plaintext athlete id, incremental cursor, and backfill marker on `User`; `STRAVA` added to `MeasurementSource`. - Token model follows Oura's reactive-refresh-on-401 with a compare-and-set on the stored refresh ciphertext, because Strava rotates its refresh token on every refresh. - Client over safeFetch: authorization-code + refresh, activity list + budget- aware per-activity detail (for calories/HR), honouring the 200/15min cap; each activity maps to one `Workout` row. Activity name/description stay in metadata as data, never surfaced or interpolated. - Dedup reuses the existing source-agnostic two-layer picker: `STRAVA` slots into the workout ladder below the device-native wearables and above MANUAL/IMPORT, so an Apple-Watch + Strava twin collapses to one canonical row. - Poll-cohort hourly sync (:17) + self-converging boot backfill, wired into the integration-sync registrar. Webhook deferred. - OAuth routes (connect/callback/credentials/disconnect/status/test) with signed state, cross-account re-auth rejection, and a surgical `www.strava.com` connect-src gated to the Strava surfaces. - Settings card + consolidated status envelope entry; env + compose whitelist; i18n across all six locales. - Garmin: no direct connector is possible (business-partner-only). Add docs/integrations/garmin.md documenting the Apple Health / Google Health Connect path and Garmin's withheld proprietary scores, cross-linked from both platform docs, plus a non-connector info note in the Connections panel. Regenerate docs/api/openapi.yaml for the new `STRAVA` MeasurementSource value.
102 lines
3.1 KiB
TypeScript
102 lines
3.1 KiB
TypeScript
import { prisma } from "@/lib/db";
|
|
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
|
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
|
import { annotate } from "@/lib/logging/context";
|
|
import { auditLog } from "@/lib/auth/audit";
|
|
import { markDisconnected } from "@/lib/integrations/status";
|
|
import {
|
|
storeStravaClientCredentials,
|
|
clearStravaClientCredentials,
|
|
} from "@/lib/strava/credentials";
|
|
import { stravaCredentialsSchema } from "@/lib/validations/strava";
|
|
import { NextRequest } from "next/server";
|
|
import { z } from "zod/v4";
|
|
|
|
/**
|
|
* v1.28.x — per-user Strava BYO-key credentials.
|
|
*
|
|
* Whether the user has their own Strava client id/secret stored. The
|
|
* connect/callback/status routes resolve credentials DB-first then env, so an
|
|
* unset pair simply falls back to the shared env app.
|
|
*/
|
|
export const GET = apiHandler(async () => {
|
|
const { user } = await requireAuth();
|
|
annotate({ action: { name: "strava.credentials.get" } });
|
|
|
|
const dbUser = await prisma.user.findUnique({
|
|
where: { id: user.id },
|
|
select: {
|
|
stravaClientIdEncrypted: true,
|
|
stravaClientSecretEncrypted: true,
|
|
},
|
|
});
|
|
|
|
return apiSuccess({
|
|
hasCredentials:
|
|
!!dbUser?.stravaClientIdEncrypted &&
|
|
!!dbUser?.stravaClientSecretEncrypted,
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Save Strava OAuth client credentials (encrypted at rest).
|
|
*/
|
|
export const PUT = apiHandler(async (request: NextRequest) => {
|
|
const { user } = await requireAuth();
|
|
annotate({ action: { name: "strava.credentials.update" } });
|
|
|
|
const { data: body, error: jsonError } = await safeJson(request, {
|
|
maxBytes: 64 * 1024,
|
|
});
|
|
if (jsonError) return jsonError;
|
|
|
|
const result = z.safeParse(stravaCredentialsSchema, body);
|
|
if (!result.success) {
|
|
return apiError("Client ID and Client Secret are required", 422);
|
|
}
|
|
|
|
await storeStravaClientCredentials(
|
|
user.id,
|
|
result.data.clientId,
|
|
result.data.clientSecret,
|
|
);
|
|
|
|
return apiSuccess({ updated: true });
|
|
});
|
|
|
|
/**
|
|
* Delete Strava credentials and the active connection.
|
|
*
|
|
* Deleting the BYO credentials drops a live connection (a token minted against
|
|
* the deleted app is now orphaned), so when an access token was present this
|
|
* mirrors `/api/strava/disconnect`: audit the event and park the integration
|
|
* ledger at `disconnected` rather than leaving it stale at its last state.
|
|
*/
|
|
export const DELETE = apiHandler(async () => {
|
|
const { user } = await requireAuth();
|
|
annotate({ action: { name: "strava.credentials.delete" } });
|
|
|
|
const dbUser = await prisma.user.findUnique({
|
|
where: { id: user.id },
|
|
select: { stravaAccessTokenEncrypted: true },
|
|
});
|
|
const wasConnected = !!dbUser?.stravaAccessTokenEncrypted;
|
|
|
|
await prisma.user.update({
|
|
where: { id: user.id },
|
|
data: {
|
|
stravaAccessTokenEncrypted: null,
|
|
stravaRefreshTokenEncrypted: null,
|
|
stravaAthleteId: null,
|
|
},
|
|
});
|
|
await clearStravaClientCredentials(user.id);
|
|
|
|
if (wasConnected) {
|
|
await auditLog("strava.credentials.delete", { userId: user.id });
|
|
await markDisconnected(user.id, "strava");
|
|
}
|
|
|
|
return apiSuccess({ deleted: true });
|
|
});
|