- 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>
230 lines
7.3 KiB
TypeScript
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);
|
|
}
|