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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user