From 3a06bff4d4d1f52e4bda63ab5a1363426326d454 Mon Sep 17 00:00:00 2001 From: Petru Paler Date: Thu, 15 Jan 2026 07:38:37 +0000 Subject: [PATCH] Fix Garmin sync to handle PocketBase date strings PocketBase returns date fields as ISO strings, not Date objects. The sync was failing with "e.getTime is not a function" because the code expected Date objects. - Export mapRecordToUser from pocketbase.ts - Use mapRecordToUser in cron route to properly parse dates - Add test for handling date fields as ISO strings Co-Authored-By: Claude Opus 4.5 --- src/app/api/cron/garmin-sync/route.test.ts | 57 ++++++++++++++++++++++ src/app/api/cron/garmin-sync/route.ts | 10 ++-- src/lib/pocketbase.ts | 2 +- 3 files changed, 63 insertions(+), 6 deletions(-) diff --git a/src/app/api/cron/garmin-sync/route.test.ts b/src/app/api/cron/garmin-sync/route.test.ts index a978f91..d8fbd35 100644 --- a/src/app/api/cron/garmin-sync/route.test.ts +++ b/src/app/api/cron/garmin-sync/route.test.ts @@ -11,6 +11,15 @@ const mockPbCreate = vi.fn().mockResolvedValue({ id: "log123" }); // Track user updates const mockPbUpdate = vi.fn().mockResolvedValue({}); +// Helper to parse date values - handles both Date objects and ISO strings +function parseDate(value: unknown): Date | null { + if (!value) return null; + if (value instanceof Date) return value; + if (typeof value !== "string") return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +} + // Mock PocketBase vi.mock("@/lib/pocketbase", () => ({ createPocketBaseClient: vi.fn(() => ({ @@ -26,6 +35,23 @@ vi.mock("@/lib/pocketbase", () => ({ authWithPassword: vi.fn().mockResolvedValue({ token: "admin-token" }), })), })), + mapRecordToUser: vi.fn((record: Record) => ({ + id: record.id, + email: record.email, + garminConnected: record.garminConnected, + garminOauth1Token: record.garminOauth1Token, + garminOauth2Token: record.garminOauth2Token, + garminTokenExpiresAt: parseDate(record.garminTokenExpiresAt), + garminRefreshTokenExpiresAt: parseDate(record.garminRefreshTokenExpiresAt), + calendarToken: record.calendarToken, + lastPeriodDate: parseDate(record.lastPeriodDate), + cycleLength: record.cycleLength, + notificationTime: record.notificationTime, + timezone: record.timezone, + activeOverrides: record.activeOverrides || [], + created: new Date(record.created as string), + updated: new Date(record.updated as string), + })), })); // Mock decryption @@ -218,6 +244,37 @@ describe("POST /api/cron/garmin-sync", () => { expect(body.usersProcessed).toBe(0); expect(body.success).toBe(true); }); + + it("handles date fields as ISO strings from PocketBase", async () => { + // PocketBase returns date fields as ISO strings, not Date objects + // This simulates the raw response from pb.collection("users").getFullList() + const rawPocketBaseRecord = { + id: "user123", + email: "test@example.com", + garminConnected: true, + garminOauth1Token: "encrypted:oauth1-token", + garminOauth2Token: "encrypted:oauth2-token", + garminTokenExpiresAt: "2026-02-15T00:00:00.000Z", // ISO string, not Date + garminRefreshTokenExpiresAt: "2026-02-15T00:00:00.000Z", // ISO string, not Date + calendarToken: "cal-token", + lastPeriodDate: "2025-01-01T00:00:00.000Z", // ISO string, not Date + cycleLength: 28, + notificationTime: "07:00", + timezone: "America/New_York", + activeOverrides: [], + created: "2024-01-01T00:00:00.000Z", + updated: "2025-01-10T00:00:00.000Z", + }; + // Cast to User to simulate what getFullList() returns + mockUsers = [rawPocketBaseRecord as unknown as User]; + + const response = await POST(createMockRequest(`Bearer ${validSecret}`)); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.usersProcessed).toBe(1); + expect(body.errors).toBe(0); + }); }); describe("Token handling", () => { diff --git a/src/app/api/cron/garmin-sync/route.ts b/src/app/api/cron/garmin-sync/route.ts index 3410da1..b49d2b0 100644 --- a/src/app/api/cron/garmin-sync/route.ts +++ b/src/app/api/cron/garmin-sync/route.ts @@ -22,8 +22,7 @@ import { garminSyncDuration, garminSyncTotal, } from "@/lib/metrics"; -import { createPocketBaseClient } from "@/lib/pocketbase"; -import type { User } from "@/types"; +import { createPocketBaseClient, mapRecordToUser } from "@/lib/pocketbase"; interface SyncResult { success: boolean; @@ -84,9 +83,10 @@ export async function POST(request: Request) { ); } - // 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(); + // Fetch all users and map to typed User objects (PocketBase returns dates as strings) + // Filter to users with Garmin connected and required date fields + const rawUsers = await pb.collection("users").getFullList(); + const allUsers = rawUsers.map(mapRecordToUser); const users = allUsers.filter( (u) => u.garminConnected && u.garminTokenExpiresAt && u.lastPeriodDate, ); diff --git a/src/lib/pocketbase.ts b/src/lib/pocketbase.ts index 9f3dfd8..056ee93 100644 --- a/src/lib/pocketbase.ts +++ b/src/lib/pocketbase.ts @@ -88,7 +88,7 @@ function parseDate(value: unknown): Date | null { /** * Maps a PocketBase record to our typed User interface. */ -function mapRecordToUser(record: RecordModel): User { +export function mapRecordToUser(record: RecordModel): User { return { id: record.id, email: record.email as string,