import { GarminConnect } from "garmin-connect"; import type { IActivity } from "garmin-connect/dist/garmin/types/activity"; import type { IOauth1Token } from "garmin-connect/dist/garmin/types"; import type { RoutePoint, RunningActivityInput } from "@/lib/models/running"; import { getSavedOauth1Token } from "@/lib/models/garmin-auth"; import { completeMfaAndGetTicket, loginAndGetTicket, type GarminPendingMfa } from "@/lib/garmin/sso"; const FETCH_LIMIT = 50; export class GarminLoginRequiredError extends Error { constructor() { super("Wymagane logowanie do Garmin Connect."); } } function parseGarminDate(value: string): Date { return new Date(`${value.replace(" ", "T")}Z`); } function isRunningActivity(activity: IActivity): boolean { return activity.activityType?.typeKey?.toLowerCase().includes("running") ?? false; } function toNumber(value: unknown): number | undefined { return typeof value === "number" && value > 0 ? value : undefined; } function toText(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } function mapActivity(activity: IActivity): RunningActivityInput { return { garminActivityId: activity.activityId, name: activity.activityName, startTime: parseGarminDate(activity.startTimeGMT), durationSec: activity.duration, distanceM: activity.distance, avgPaceSecPerKm: activity.averageSpeed > 0 ? 1000 / activity.averageSpeed : 0, avgHr: activity.averageHR || undefined, maxHr: activity.maxHR || undefined, calories: activity.calories || undefined, elevationGainM: activity.elevationGain || undefined, avgCadence: activity.averageRunningCadenceInStepsPerMinute || undefined, avgVerticalOscillationCm: toNumber(activity.avgVerticalOscillation), avgGroundContactTimeMs: toNumber(activity.avgGroundContactTime), avgStrideLengthCm: activity.avgStrideLength || undefined, avgGroundContactBalanceLeftPct: toNumber(activity.avgGroundContactBalance), avgVerticalRatioPct: toNumber(activity.avgVerticalRatio), vo2Max: activity.vO2MaxValue || undefined, aerobicTrainingEffect: toNumber(activity.aerobicTrainingEffect), anaerobicTrainingEffect: toNumber(activity.anaerobicTrainingEffect), trainingEffectLabel: toText(activity.trainingEffectLabel), avgPowerW: toNumber(activity.avgPower), maxPowerW: toNumber(activity.maxPower), normPowerW: toNumber(activity.normPower), avgRespirationRate: toNumber(activity.avgRespirationRate), hasRoute: activity.hasPolyline || undefined, }; } const GC_API = "https://connectapi.garmin.com"; const MAX_POLYLINE_POINTS = 500; type GarminPolylinePoint = { lat: number; lon: number; altitude?: number }; type GarminActivityDetailsResponse = { geoPolylineDTO?: { polyline?: GarminPolylinePoint[] }; }; export async function fetchActivityRoutePoints( client: GarminConnect, garminActivityId: number ): Promise { const url = `${GC_API}/activity-service/activity/${garminActivityId}/details?maxPolylineSize=${MAX_POLYLINE_POINTS}`; const data = await client.get(url); const polyline = data?.geoPolylineDTO?.polyline; if (!Array.isArray(polyline) || polyline.length === 0) return null; return polyline.map((p) => [p.lat, p.lon] as RoutePoint); } function getCredentials(): { username: string; password: string } { const username = process.env.GARMIN_EMAIL; const password = process.env.GARMIN_PASSWORD; if (!username || !password) { throw new Error("Brak danych logowania do Garmin Connect (GARMIN_EMAIL / GARMIN_PASSWORD)."); } return { username, password }; } async function exchangeOauth1Token(client: GarminConnect, oauth1Token: IOauth1Token): Promise { const http = client.client; if (!http.OAUTH_CONSUMER) { await http.fetchOauthConsumer(); } const consumer = http.OAUTH_CONSUMER; if (!consumer) { throw new Error("Nie udało się pobrać konfiguracji OAuth Garmin."); } const oauth = http.getOauthClient(consumer); http.oauth1Token = oauth1Token; await http.exchange({ oauth, token: oauth1Token }); } /** * Returns a client authenticated using a previously saved OAuth1 token * (long-lived, survives across syncs) - no MFA needed if it's still valid. */ export async function getAuthorizedClient(): Promise { const saved = await getSavedOauth1Token(); if (!saved) { throw new GarminLoginRequiredError(); } const client = new GarminConnect({ username: "", password: "" }); try { await exchangeOauth1Token(client, saved); } catch { throw new GarminLoginRequiredError(); } return client; } async function establishClientFromTicket(ticket: string): Promise<{ client: GarminConnect; oauth1Token: IOauth1Token }> { const client = new GarminConnect({ username: "", password: "" }); await client.client.fetchOauthConsumer(); const oauth1 = await client.client.getOauth1Token(ticket); await client.client.exchange(oauth1); return { client, oauth1Token: oauth1.token }; } /** * Starts a fresh SSO login using env credentials. If the account requires * MFA, returns the pending state needed to complete it via * `completeGarminMfaLogin` once the user supplies the emailed code. */ export async function beginGarminLogin(): Promise< { client: GarminConnect; oauth1Token: IOauth1Token } | { mfaRequired: true; pendingState: GarminPendingMfa } > { const { username, password } = getCredentials(); const result = await loginAndGetTicket(username, password); if ("mfaRequired" in result) return result; return establishClientFromTicket(result.ticket); } export async function completeGarminMfaLogin( pendingState: GarminPendingMfa, code: string ): Promise<{ client: GarminConnect; oauth1Token: IOauth1Token }> { const ticket = await completeMfaAndGetTicket(pendingState, code); return establishClientFromTicket(ticket); } /** * Returns all recent running activities (mapped), regardless of `since` - * callers should upsert all of them so previously-synced activities get * backfilled with newly added metric fields, but can use `since` to decide * which ones are "new" for reporting purposes. */ export async function fetchRunningActivities(client: GarminConnect): Promise { const activities = await client.getActivities(0, FETCH_LIMIT); return activities.filter(isRunningActivity).map(mapActivity); }