Files
phaseflow/src/app/api/cron/garmin-sync/route.ts
Petru Paler b221acee40 Implement automatic Garmin token refresh and fix expiry tracking
- Add OAuth1 to OAuth2 token exchange using Garmin's exchange endpoint
- Track refresh token expiry (~30 days) instead of access token expiry (~21 hours)
- Auto-refresh access tokens in cron sync before they expire
- Update Python script to output refresh_token_expires_at
- Add garminRefreshTokenExpiresAt field to User type and database schema
- Fix token input UX: show when warning active, not just when disconnected
- Add Cache-Control headers to /api/user and /api/garmin/status to prevent stale data
- Add oauth-1.0a package for OAuth1 signature generation

The system now automatically refreshes OAuth2 tokens using the stored OAuth1 token,
so users only need to re-run the Python auth script every ~30 days (when refresh
token expires) instead of every ~21 hours (when access token expires).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 20:33:10 +00:00

230 lines
7.3 KiB
TypeScript

// 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<User>();
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);
}