// ABOUTME: Cron endpoint for syncing Garmin data. // ABOUTME: Fetches body battery, HRV, and intensity minutes for all users. import { NextResponse } from "next/server"; import { getCycleDay, getPhase, getPhaseLimit } from "@/lib/cycle"; import { getDecisionWithOverrides } from "@/lib/decision-engine"; import { sendTokenExpirationWarning } from "@/lib/email"; import { decrypt, encrypt } from "@/lib/encryption"; import { fetchBodyBattery, fetchHrvStatus, fetchIntensityMinutes, } from "@/lib/garmin"; import { exchangeOAuth1ForOAuth2, isAccessTokenExpired, type OAuth1TokenData, } from "@/lib/garmin-auth"; import { logger } from "@/lib/logger"; import { activeUsersGauge, garminSyncDuration, garminSyncTotal, } from "@/lib/metrics"; import { createPocketBaseClient } from "@/lib/pocketbase"; import type { User } from "@/types"; interface SyncResult { success: boolean; usersProcessed: number; errors: number; skippedExpired: number; tokensRefreshed: number; warningsSent: number; timestamp: string; } export async function POST(request: Request) { // Verify cron secret const authHeader = request.headers.get("authorization"); const expectedSecret = process.env.CRON_SECRET; if (!expectedSecret || authHeader !== `Bearer ${expectedSecret}`) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } const syncStartTime = Date.now(); const result: SyncResult = { success: true, usersProcessed: 0, errors: 0, skippedExpired: 0, tokensRefreshed: 0, warningsSent: 0, timestamp: new Date().toISOString(), }; const pb = createPocketBaseClient(); // Fetch all users (we'll filter garminConnected in code to avoid PocketBase query syntax issues) // Also filter out users without required date fields (garminTokenExpiresAt, lastPeriodDate) const allUsers = await pb.collection("users").getFullList(); const users = allUsers.filter( (u) => u.garminConnected && u.garminTokenExpiresAt && u.lastPeriodDate, ); const today = new Date().toISOString().split("T")[0]; for (const user of users) { const userSyncStartTime = Date.now(); try { // Check if refresh token is expired (user needs to re-auth via Python script) // Note: garminTokenExpiresAt and lastPeriodDate are guaranteed non-null by filter above if (user.garminRefreshTokenExpiresAt) { const refreshTokenExpired = new Date(user.garminRefreshTokenExpiresAt) <= new Date(); if (refreshTokenExpired) { logger.info( { userId: user.id }, "Refresh token expired, skipping user", ); result.skippedExpired++; continue; } } // Log sync start logger.info({ userId: user.id }, "Garmin sync start"); // Check for refresh token expiration warnings (exactly 14 or 7 days) if (user.garminRefreshTokenExpiresAt) { const refreshExpiry = new Date(user.garminRefreshTokenExpiresAt); const now = new Date(); const diffMs = refreshExpiry.getTime() - now.getTime(); const daysRemaining = Math.floor(diffMs / (1000 * 60 * 60 * 24)); if (daysRemaining === 14 || daysRemaining === 7) { try { await sendTokenExpirationWarning( user.email, daysRemaining, user.id, ); result.warningsSent++; } catch { // Continue processing even if warning email fails } } } // Decrypt tokens const oauth1Json = decrypt(user.garminOauth1Token); const oauth1Data = JSON.parse(oauth1Json) as OAuth1TokenData; const oauth2Json = decrypt(user.garminOauth2Token); let oauth2Data = JSON.parse(oauth2Json); // Check if access token needs refresh // biome-ignore lint/style/noNonNullAssertion: filtered above const accessTokenExpiresAt = user.garminTokenExpiresAt!; if (isAccessTokenExpired(accessTokenExpiresAt)) { logger.info({ userId: user.id }, "Access token expired, refreshing"); try { const refreshResult = await exchangeOAuth1ForOAuth2(oauth1Data); oauth2Data = refreshResult.oauth2; // Update stored tokens const encryptedOauth2 = encrypt(JSON.stringify(oauth2Data)); await pb.collection("users").update(user.id, { garminOauth2Token: encryptedOauth2, garminTokenExpiresAt: refreshResult.expires_at, garminRefreshTokenExpiresAt: refreshResult.refresh_token_expires_at, }); result.tokensRefreshed++; logger.info({ userId: user.id }, "Access token refreshed"); } catch (refreshError) { logger.error( { userId: user.id, err: refreshError }, "Failed to refresh access token", ); result.errors++; garminSyncTotal.inc({ status: "failure" }); continue; } } const accessToken = oauth2Data.access_token; // Fetch Garmin data const [hrvStatus, bodyBattery, weekIntensityMinutes] = await Promise.all([ fetchHrvStatus(today, accessToken), fetchBodyBattery(today, accessToken), fetchIntensityMinutes(accessToken), ]); // Calculate cycle info (lastPeriodDate guaranteed non-null by filter above) const cycleDay = getCycleDay( // biome-ignore lint/style/noNonNullAssertion: filtered above user.lastPeriodDate!, user.cycleLength, new Date(), ); const phase = getPhase(cycleDay, user.cycleLength); const phaseLimit = getPhaseLimit(phase); const remainingMinutes = Math.max(0, phaseLimit - weekIntensityMinutes); // Calculate training decision const decision = getDecisionWithOverrides( { hrvStatus, bbYesterdayLow: bodyBattery.yesterdayLow ?? 100, phase, weekIntensity: weekIntensityMinutes, phaseLimit, bbCurrent: bodyBattery.current ?? 100, }, user.activeOverrides, ); // Create DailyLog entry await pb.collection("dailyLogs").create({ user: user.id, date: today, cycleDay, phase, bodyBatteryCurrent: bodyBattery.current, bodyBatteryYesterdayLow: bodyBattery.yesterdayLow, hrvStatus, weekIntensityMinutes, phaseLimit, remainingMinutes, trainingDecision: decision.status, decisionReason: decision.reason, notificationSentAt: null, }); // Log sync complete with metrics const userSyncDuration = Date.now() - userSyncStartTime; logger.info( { userId: user.id, duration_ms: userSyncDuration, metrics: { bodyBattery: bodyBattery.current, hrvStatus, }, }, "Garmin sync complete", ); result.usersProcessed++; garminSyncTotal.inc({ status: "success" }); } catch (error) { // Log sync failure logger.error({ userId: user.id, err: error }, "Garmin sync failure"); result.errors++; garminSyncTotal.inc({ status: "failure" }); } } // Record sync duration and active users const syncDurationSeconds = (Date.now() - syncStartTime) / 1000; garminSyncDuration.observe(syncDurationSeconds); activeUsersGauge.set(result.usersProcessed); return NextResponse.json(result); }