Implement Garmin sync cron endpoint (P2.4)

Add daily sync functionality for Garmin biometric data:
- Fetch all users with garminConnected=true
- Skip users with expired tokens
- Decrypt OAuth2 tokens and fetch HRV, Body Battery, Intensity Minutes
- Calculate cycle day, phase, phase limit, remaining minutes
- Compute training decision using decision engine
- Create DailyLog entries for each user
- Return sync summary with usersProcessed, errors, skippedExpired, timestamp

Includes 22 tests covering:
- CRON_SECRET authentication
- User iteration and filtering
- Token decryption and expiry handling
- Garmin API data fetching
- DailyLog creation with all required fields
- Error handling and graceful degradation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-10 19:50:26 +00:00
parent 0fc25a49f1
commit fc970a2c61
3 changed files with 509 additions and 8 deletions

View File

@@ -2,6 +2,26 @@
// 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 { decrypt } from "@/lib/encryption";
import {
fetchBodyBattery,
fetchHrvStatus,
fetchIntensityMinutes,
isTokenExpired,
} from "@/lib/garmin";
import { createPocketBaseClient } from "@/lib/pocketbase";
import type { GarminTokens, User } from "@/types";
interface SyncResult {
success: boolean;
usersProcessed: number;
errors: number;
skippedExpired: number;
timestamp: string;
}
export async function POST(request: Request) {
// Verify cron secret
const authHeader = request.headers.get("authorization");
@@ -11,6 +31,93 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// TODO: Implement Garmin data sync
return NextResponse.json({ message: "Not implemented" }, { status: 501 });
const result: SyncResult = {
success: true,
usersProcessed: 0,
errors: 0,
skippedExpired: 0,
timestamp: new Date().toISOString(),
};
const pb = createPocketBaseClient();
// Fetch all users (we'll filter garminConnected in code to avoid PocketBase query syntax issues)
const allUsers = await pb.collection("users").getFullList<User>();
const users = allUsers.filter((u) => u.garminConnected);
const today = new Date().toISOString().split("T")[0];
for (const user of users) {
try {
// Check if tokens are expired
const tokens: GarminTokens = {
oauth1: user.garminOauth1Token,
oauth2: user.garminOauth2Token,
expires_at: user.garminTokenExpiresAt.toISOString(),
};
if (isTokenExpired(tokens)) {
result.skippedExpired++;
continue;
}
// Decrypt OAuth2 token
const oauth2Json = decrypt(user.garminOauth2Token);
const oauth2Data = JSON.parse(oauth2Json);
const accessToken = oauth2Data.accessToken;
// Fetch Garmin data
const [hrvStatus, bodyBattery, weekIntensityMinutes] = await Promise.all([
fetchHrvStatus(today, accessToken),
fetchBodyBattery(today, accessToken),
fetchIntensityMinutes(accessToken),
]);
// Calculate cycle info
const cycleDay = getCycleDay(
user.lastPeriodDate,
user.cycleLength,
new Date(),
);
const phase = getPhase(cycleDay);
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,
});
result.usersProcessed++;
} catch {
result.errors++;
}
}
return NextResponse.json(result);
}