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:
@@ -37,7 +37,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
|
|||||||
| GET /api/garmin/status | **COMPLETE** | Returns connection status, expiry, warning level (11 tests) |
|
| GET /api/garmin/status | **COMPLETE** | Returns connection status, expiry, warning level (11 tests) |
|
||||||
| GET /api/calendar/[userId]/[token].ics | 501 | Has param extraction, core logic TODO |
|
| GET /api/calendar/[userId]/[token].ics | 501 | Has param extraction, core logic TODO |
|
||||||
| POST /api/calendar/regenerate-token | 501 | Returns Not Implemented |
|
| POST /api/calendar/regenerate-token | 501 | Returns Not Implemented |
|
||||||
| POST /api/cron/garmin-sync | 501 | Has CRON_SECRET auth check, core logic TODO |
|
| POST /api/cron/garmin-sync | **COMPLETE** | Syncs Garmin data for all users, creates DailyLogs (22 tests) |
|
||||||
| POST /api/cron/notifications | 501 | Has CRON_SECRET auth check, core logic TODO |
|
| POST /api/cron/notifications | 501 | Has CRON_SECRET auth check, core logic TODO |
|
||||||
|
|
||||||
### Pages (7 total)
|
### Pages (7 total)
|
||||||
@@ -84,6 +84,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
|
|||||||
| `src/lib/garmin.test.ts` | **EXISTS** - 33 tests (fetchGarminData, fetchHrvStatus, fetchBodyBattery, fetchIntensityMinutes, token expiry, error handling) |
|
| `src/lib/garmin.test.ts` | **EXISTS** - 33 tests (fetchGarminData, fetchHrvStatus, fetchBodyBattery, fetchIntensityMinutes, token expiry, error handling) |
|
||||||
| `src/app/api/garmin/tokens/route.test.ts` | **EXISTS** - 15 tests (POST/DELETE tokens, encryption, validation, auth) |
|
| `src/app/api/garmin/tokens/route.test.ts` | **EXISTS** - 15 tests (POST/DELETE tokens, encryption, validation, auth) |
|
||||||
| `src/app/api/garmin/status/route.test.ts` | **EXISTS** - 11 tests (connection status, expiry, warning levels) |
|
| `src/app/api/garmin/status/route.test.ts` | **EXISTS** - 11 tests (connection status, expiry, warning levels) |
|
||||||
|
| `src/app/api/cron/garmin-sync/route.test.ts` | **EXISTS** - 22 tests (auth, user iteration, token handling, Garmin data fetching, DailyLog creation, error handling) |
|
||||||
| E2E tests | **NONE** |
|
| E2E tests | **NONE** |
|
||||||
|
|
||||||
### Critical Business Rules (from Spec)
|
### Critical Business Rules (from Spec)
|
||||||
@@ -290,15 +291,22 @@ Full feature set for production use.
|
|||||||
- **Why:** Users need visibility into their Garmin connection
|
- **Why:** Users need visibility into their Garmin connection
|
||||||
- **Depends On:** P0.1, P0.2, P2.1
|
- **Depends On:** P0.1, P0.2, P2.1
|
||||||
|
|
||||||
### P2.4: POST /api/cron/garmin-sync Implementation
|
### P2.4: POST /api/cron/garmin-sync Implementation ✅ COMPLETE
|
||||||
- [ ] Daily sync of all Garmin data for all users
|
- [x] Daily sync of all Garmin data for all users
|
||||||
- **Files:**
|
- **Files:**
|
||||||
- `src/app/api/cron/garmin-sync/route.ts` - Iterate users, fetch data, store DailyLog
|
- `src/app/api/cron/garmin-sync/route.ts` - Iterates users, fetches data, stores DailyLog
|
||||||
- **Tests:**
|
- **Tests:**
|
||||||
- `src/app/api/cron/garmin-sync/route.test.ts` - Test auth, user iteration, data persistence
|
- `src/app/api/cron/garmin-sync/route.test.ts` - 22 tests covering auth, user iteration, token handling, Garmin data fetching, DailyLog creation, error handling
|
||||||
|
- **Features Implemented:**
|
||||||
|
- Fetches all users with garminConnected=true
|
||||||
|
- Skips users with expired tokens
|
||||||
|
- Decrypts OAuth2 tokens and fetches HRV, Body Battery, Intensity Minutes
|
||||||
|
- Calculates cycle day, phase, phase limit, remaining minutes
|
||||||
|
- Computes training decision using decision engine
|
||||||
|
- Creates DailyLog entries for each user
|
||||||
|
- Returns sync summary (usersProcessed, errors, skippedExpired, timestamp)
|
||||||
- **Why:** Automated data sync is required for morning notifications
|
- **Why:** Automated data sync is required for morning notifications
|
||||||
- **Depends On:** P2.1, P2.2
|
- **Depends On:** P2.1, P2.2
|
||||||
- **Note:** Route exists with CRON_SECRET auth check, needs core logic
|
|
||||||
|
|
||||||
### P2.5: POST /api/cron/notifications Implementation
|
### P2.5: POST /api/cron/notifications Implementation
|
||||||
- [ ] Send daily email notifications at user's preferred time
|
- [ ] Send daily email notifications at user's preferred time
|
||||||
@@ -568,6 +576,7 @@ P2.14 Mini calendar
|
|||||||
- [x] **POST /api/garmin/tokens** - Stores encrypted Garmin OAuth tokens, 15 tests (P2.2)
|
- [x] **POST /api/garmin/tokens** - Stores encrypted Garmin OAuth tokens, 15 tests (P2.2)
|
||||||
- [x] **DELETE /api/garmin/tokens** - Clears tokens and disconnects Garmin, 15 tests (P2.2)
|
- [x] **DELETE /api/garmin/tokens** - Clears tokens and disconnects Garmin, 15 tests (P2.2)
|
||||||
- [x] **GET /api/garmin/status** - Returns connection status, expiry, warning level, 11 tests (P2.3)
|
- [x] **GET /api/garmin/status** - Returns connection status, expiry, warning level, 11 tests (P2.3)
|
||||||
|
- [x] **POST /api/cron/garmin-sync** - Daily sync of Garmin data for all connected users, creates DailyLogs, 22 tests (P2.4)
|
||||||
|
|
||||||
### Pages
|
### Pages
|
||||||
- [x] **Login Page** - Email/password form with PocketBase auth, error handling, loading states, redirect, 14 tests (P1.6)
|
- [x] **Login Page** - Email/password form with PocketBase auth, error handling, loading states, redirect, 14 tests (P1.6)
|
||||||
|
|||||||
385
src/app/api/cron/garmin-sync/route.test.ts
Normal file
385
src/app/api/cron/garmin-sync/route.test.ts
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
// ABOUTME: Unit tests for Garmin sync cron endpoint.
|
||||||
|
// ABOUTME: Tests daily sync of Garmin biometric data for all connected users.
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { User } from "@/types";
|
||||||
|
|
||||||
|
// Mock users returned by PocketBase
|
||||||
|
let mockUsers: User[] = [];
|
||||||
|
// Track DailyLog creations
|
||||||
|
const mockPbCreate = vi.fn().mockResolvedValue({ id: "log123" });
|
||||||
|
|
||||||
|
// Mock PocketBase
|
||||||
|
vi.mock("@/lib/pocketbase", () => ({
|
||||||
|
createPocketBaseClient: vi.fn(() => ({
|
||||||
|
collection: vi.fn((name: string) => ({
|
||||||
|
getFullList: vi.fn(async () => {
|
||||||
|
if (name === "users") {
|
||||||
|
return mockUsers;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}),
|
||||||
|
create: mockPbCreate,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock decryption
|
||||||
|
const mockDecrypt = vi.fn((ciphertext: string) => {
|
||||||
|
// Return mock OAuth2 token JSON
|
||||||
|
if (ciphertext.includes("oauth2")) {
|
||||||
|
return JSON.stringify({ accessToken: "mock-token-123" });
|
||||||
|
}
|
||||||
|
return ciphertext.replace("encrypted:", "");
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/lib/encryption", () => ({
|
||||||
|
decrypt: (ciphertext: string) => mockDecrypt(ciphertext),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock Garmin API functions
|
||||||
|
const mockFetchHrvStatus = vi.fn().mockResolvedValue("Balanced");
|
||||||
|
const mockFetchBodyBattery = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ current: 85, yesterdayLow: 45 });
|
||||||
|
const mockFetchIntensityMinutes = vi.fn().mockResolvedValue(60);
|
||||||
|
const mockIsTokenExpired = vi.fn().mockReturnValue(false);
|
||||||
|
|
||||||
|
vi.mock("@/lib/garmin", () => ({
|
||||||
|
fetchHrvStatus: (...args: unknown[]) => mockFetchHrvStatus(...args),
|
||||||
|
fetchBodyBattery: (...args: unknown[]) => mockFetchBodyBattery(...args),
|
||||||
|
fetchIntensityMinutes: (...args: unknown[]) =>
|
||||||
|
mockFetchIntensityMinutes(...args),
|
||||||
|
isTokenExpired: (...args: unknown[]) => mockIsTokenExpired(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { POST } from "./route";
|
||||||
|
|
||||||
|
describe("POST /api/cron/garmin-sync", () => {
|
||||||
|
const validSecret = "test-cron-secret";
|
||||||
|
|
||||||
|
// Helper to create a mock user
|
||||||
|
function createMockUser(overrides: Partial<User> = {}): User {
|
||||||
|
return {
|
||||||
|
id: "user123",
|
||||||
|
email: "test@example.com",
|
||||||
|
garminConnected: true,
|
||||||
|
garminOauth1Token: "encrypted:oauth1-token",
|
||||||
|
garminOauth2Token: "encrypted:oauth2-token",
|
||||||
|
garminTokenExpiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now
|
||||||
|
calendarToken: "cal-token",
|
||||||
|
lastPeriodDate: new Date("2025-01-01"),
|
||||||
|
cycleLength: 28,
|
||||||
|
notificationTime: "07:00",
|
||||||
|
timezone: "America/New_York",
|
||||||
|
activeOverrides: [],
|
||||||
|
created: new Date("2024-01-01"),
|
||||||
|
updated: new Date("2025-01-10"),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to create mock request with optional auth header
|
||||||
|
function createMockRequest(authHeader?: string): Request {
|
||||||
|
const headers = new Headers();
|
||||||
|
if (authHeader) {
|
||||||
|
headers.set("authorization", authHeader);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
headers,
|
||||||
|
} as unknown as Request;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockUsers = [];
|
||||||
|
process.env.CRON_SECRET = validSecret;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Authentication", () => {
|
||||||
|
it("returns 401 when authorization header is missing", async () => {
|
||||||
|
const response = await POST(createMockRequest());
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.error).toBe("Unauthorized");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 401 when secret is incorrect", async () => {
|
||||||
|
const response = await POST(createMockRequest("Bearer wrong-secret"));
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.error).toBe("Unauthorized");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 401 when CRON_SECRET env var is not set", async () => {
|
||||||
|
process.env.CRON_SECRET = "";
|
||||||
|
|
||||||
|
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("User fetching", () => {
|
||||||
|
it("fetches users with garminConnected=true", async () => {
|
||||||
|
mockUsers = [createMockUser()];
|
||||||
|
|
||||||
|
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.usersProcessed).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips users without Garmin connection", async () => {
|
||||||
|
mockUsers = [
|
||||||
|
createMockUser({ id: "user1", garminConnected: true }),
|
||||||
|
createMockUser({ id: "user2", garminConnected: false }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.usersProcessed).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns success with zero users when none are connected", async () => {
|
||||||
|
mockUsers = [];
|
||||||
|
|
||||||
|
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.usersProcessed).toBe(0);
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Token handling", () => {
|
||||||
|
it("decrypts OAuth2 token before making Garmin API calls", async () => {
|
||||||
|
mockUsers = [createMockUser()];
|
||||||
|
|
||||||
|
await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||||
|
|
||||||
|
expect(mockDecrypt).toHaveBeenCalledWith("encrypted:oauth2-token");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips users with expired tokens", async () => {
|
||||||
|
mockIsTokenExpired.mockReturnValue(true);
|
||||||
|
mockUsers = [createMockUser()];
|
||||||
|
|
||||||
|
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.skippedExpired).toBe(1);
|
||||||
|
expect(mockFetchHrvStatus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("processes users with valid tokens", async () => {
|
||||||
|
mockIsTokenExpired.mockReturnValue(false);
|
||||||
|
mockUsers = [createMockUser()];
|
||||||
|
|
||||||
|
await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||||
|
|
||||||
|
expect(mockFetchHrvStatus).toHaveBeenCalled();
|
||||||
|
expect(mockFetchBodyBattery).toHaveBeenCalled();
|
||||||
|
expect(mockFetchIntensityMinutes).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Garmin data fetching", () => {
|
||||||
|
it("fetches HRV status with today's date", async () => {
|
||||||
|
mockUsers = [createMockUser()];
|
||||||
|
|
||||||
|
await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||||
|
|
||||||
|
expect(mockFetchHrvStatus).toHaveBeenCalledWith(
|
||||||
|
expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
"mock-token-123",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fetches body battery with today's date", async () => {
|
||||||
|
mockUsers = [createMockUser()];
|
||||||
|
|
||||||
|
await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||||
|
|
||||||
|
expect(mockFetchBodyBattery).toHaveBeenCalledWith(
|
||||||
|
expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
"mock-token-123",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fetches intensity minutes", async () => {
|
||||||
|
mockUsers = [createMockUser()];
|
||||||
|
|
||||||
|
await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||||
|
|
||||||
|
expect(mockFetchIntensityMinutes).toHaveBeenCalledWith("mock-token-123");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DailyLog creation", () => {
|
||||||
|
it("creates DailyLog entry with fetched data", async () => {
|
||||||
|
mockUsers = [createMockUser({ lastPeriodDate: new Date("2025-01-01") })];
|
||||||
|
mockFetchHrvStatus.mockResolvedValue("Balanced");
|
||||||
|
mockFetchBodyBattery.mockResolvedValue({ current: 90, yesterdayLow: 50 });
|
||||||
|
mockFetchIntensityMinutes.mockResolvedValue(45);
|
||||||
|
|
||||||
|
await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||||
|
|
||||||
|
expect(mockPbCreate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
user: "user123",
|
||||||
|
hrvStatus: "Balanced",
|
||||||
|
bodyBatteryCurrent: 90,
|
||||||
|
bodyBatteryYesterdayLow: 50,
|
||||||
|
weekIntensityMinutes: 45,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes cycle day and phase in DailyLog", async () => {
|
||||||
|
// Set lastPeriodDate to make cycle day calculable
|
||||||
|
const lastPeriodDate = new Date();
|
||||||
|
lastPeriodDate.setDate(lastPeriodDate.getDate() - 5); // 6 days ago = cycle day 6
|
||||||
|
mockUsers = [createMockUser({ lastPeriodDate, cycleLength: 28 })];
|
||||||
|
|
||||||
|
await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||||
|
|
||||||
|
expect(mockPbCreate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
cycleDay: expect.any(Number),
|
||||||
|
phase: expect.stringMatching(
|
||||||
|
/^(MENSTRUAL|FOLLICULAR|OVULATION|EARLY_LUTEAL|LATE_LUTEAL)$/,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes phase limit and remaining minutes in DailyLog", async () => {
|
||||||
|
mockUsers = [createMockUser()];
|
||||||
|
|
||||||
|
await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||||
|
|
||||||
|
expect(mockPbCreate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
phaseLimit: expect.any(Number),
|
||||||
|
remainingMinutes: expect.any(Number),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes training decision in DailyLog", async () => {
|
||||||
|
mockUsers = [createMockUser()];
|
||||||
|
|
||||||
|
await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||||
|
|
||||||
|
expect(mockPbCreate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
trainingDecision: expect.stringMatching(
|
||||||
|
/^(REST|GENTLE|LIGHT|REDUCED|TRAIN)$/,
|
||||||
|
),
|
||||||
|
decisionReason: expect.any(String),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets date to today's date string", async () => {
|
||||||
|
mockUsers = [createMockUser()];
|
||||||
|
const today = new Date().toISOString().split("T")[0];
|
||||||
|
|
||||||
|
await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||||
|
|
||||||
|
expect(mockPbCreate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
date: today,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Error handling", () => {
|
||||||
|
it("continues processing other users when one fails", async () => {
|
||||||
|
mockUsers = [
|
||||||
|
createMockUser({ id: "user1" }),
|
||||||
|
createMockUser({ id: "user2" }),
|
||||||
|
];
|
||||||
|
// First user fails, second succeeds
|
||||||
|
mockFetchHrvStatus
|
||||||
|
.mockRejectedValueOnce(new Error("API error"))
|
||||||
|
.mockResolvedValueOnce("Balanced");
|
||||||
|
|
||||||
|
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.errors).toBe(1);
|
||||||
|
expect(body.usersProcessed).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles decryption errors gracefully", async () => {
|
||||||
|
mockUsers = [createMockUser()];
|
||||||
|
mockDecrypt.mockImplementationOnce(() => {
|
||||||
|
throw new Error("Decryption failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.errors).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles body battery null values", async () => {
|
||||||
|
mockUsers = [createMockUser()];
|
||||||
|
mockFetchBodyBattery.mockResolvedValue({
|
||||||
|
current: null,
|
||||||
|
yesterdayLow: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||||
|
|
||||||
|
expect(mockPbCreate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
bodyBatteryCurrent: null,
|
||||||
|
bodyBatteryYesterdayLow: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Response format", () => {
|
||||||
|
it("returns summary with counts", async () => {
|
||||||
|
mockUsers = [
|
||||||
|
createMockUser({ id: "user1" }),
|
||||||
|
createMockUser({ id: "user2" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body).toMatchObject({
|
||||||
|
success: true,
|
||||||
|
usersProcessed: 2,
|
||||||
|
errors: 0,
|
||||||
|
skippedExpired: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes timestamp in response", async () => {
|
||||||
|
mockUsers = [];
|
||||||
|
|
||||||
|
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||||
|
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.timestamp).toBeDefined();
|
||||||
|
expect(new Date(body.timestamp)).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,26 @@
|
|||||||
// ABOUTME: Fetches body battery, HRV, and intensity minutes for all users.
|
// ABOUTME: Fetches body battery, HRV, and intensity minutes for all users.
|
||||||
import { NextResponse } from "next/server";
|
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) {
|
export async function POST(request: Request) {
|
||||||
// Verify cron secret
|
// Verify cron secret
|
||||||
const authHeader = request.headers.get("authorization");
|
const authHeader = request.headers.get("authorization");
|
||||||
@@ -11,6 +31,93 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement Garmin data sync
|
const result: SyncResult = {
|
||||||
return NextResponse.json({ message: "Not implemented" }, { status: 501 });
|
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