From 72706bb91b5d30b6e96e04166061a73ffdbc32a6 Mon Sep 17 00:00:00 2001 From: Petru Paler Date: Mon, 12 Jan 2026 14:13:02 +0000 Subject: [PATCH] Fix Invalid Date error in auth middleware Add parseDate helper that safely returns null for empty/invalid date strings from PocketBase. This prevents RangeError when pino logger tries to serialize Invalid Date objects via toISOString(). - Make garminTokenExpiresAt and lastPeriodDate nullable in User type - Filter garmin-sync cron to skip users without required dates - Add test assertions for null date handling Co-Authored-By: Claude Opus 4.5 --- .../[userId]/[token].ics/route.test.ts | 3 +- src/app/api/cron/garmin-sync/route.ts | 14 +++++--- src/lib/pocketbase.test.ts | 35 +++++++++++++++++++ src/lib/pocketbase.ts | 13 +++++-- src/types/index.ts | 4 +-- 5 files changed, 60 insertions(+), 9 deletions(-) diff --git a/src/app/api/calendar/[userId]/[token].ics/route.test.ts b/src/app/api/calendar/[userId]/[token].ics/route.test.ts index 1903016..6b9e364 100644 --- a/src/app/api/calendar/[userId]/[token].ics/route.test.ts +++ b/src/app/api/calendar/[userId]/[token].ics/route.test.ts @@ -27,7 +27,8 @@ vi.mock("@/lib/pocketbase", () => ({ id: user.id, email: user.email, calendarToken: user.calendarToken, - lastPeriodDate: user.lastPeriodDate.toISOString(), + // biome-ignore lint/style/noNonNullAssertion: mock user has valid date + lastPeriodDate: user.lastPeriodDate!.toISOString(), cycleLength: user.cycleLength, garminConnected: user.garminConnected, }; diff --git a/src/app/api/cron/garmin-sync/route.ts b/src/app/api/cron/garmin-sync/route.ts index 0aac55f..f064e73 100644 --- a/src/app/api/cron/garmin-sync/route.ts +++ b/src/app/api/cron/garmin-sync/route.ts @@ -54,8 +54,11 @@ export async function POST(request: Request) { 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(); - const users = allUsers.filter((u) => u.garminConnected); + const users = allUsers.filter( + (u) => u.garminConnected && u.garminTokenExpiresAt && u.lastPeriodDate, + ); const today = new Date().toISOString().split("T")[0]; @@ -64,10 +67,12 @@ export async function POST(request: Request) { try { // Check if tokens are expired + // Note: garminTokenExpiresAt and lastPeriodDate are guaranteed non-null by filter above const tokens: GarminTokens = { oauth1: user.garminOauth1Token, oauth2: user.garminOauth2Token, - expires_at: user.garminTokenExpiresAt.toISOString(), + // biome-ignore lint/style/noNonNullAssertion: filtered above + expires_at: user.garminTokenExpiresAt!.toISOString(), }; if (isTokenExpired(tokens)) { @@ -101,9 +106,10 @@ export async function POST(request: Request) { fetchIntensityMinutes(accessToken), ]); - // Calculate cycle info + // Calculate cycle info (lastPeriodDate guaranteed non-null by filter above) const cycleDay = getCycleDay( - user.lastPeriodDate, + // biome-ignore lint/style/noNonNullAssertion: filtered above + user.lastPeriodDate!, user.cycleLength, new Date(), ); diff --git a/src/lib/pocketbase.test.ts b/src/lib/pocketbase.test.ts index 0d67501..f587046 100644 --- a/src/lib/pocketbase.test.ts +++ b/src/lib/pocketbase.test.ts @@ -127,6 +127,41 @@ describe("getCurrentUser", () => { expect(user).not.toBeNull(); expect(user?.activeOverrides).toEqual([]); + // Empty string dates should be parsed as null + expect(user?.garminTokenExpiresAt).toBeNull(); + }); + + it("returns null for invalid date strings", () => { + const mockRecord = { + id: "user789", + email: "invalid@test.com", + garminConnected: false, + garminOauth1Token: "", + garminOauth2Token: "", + garminTokenExpiresAt: "not-a-date", + calendarToken: "token", + lastPeriodDate: "", + cycleLength: 28, + notificationTime: "09:00", + timezone: "UTC", + activeOverrides: [], + created: "2024-01-01T00:00:00Z", + updated: "2024-01-01T00:00:00Z", + }; + + const mockPb = { + authStore: { + isValid: true, + model: mockRecord, + }, + }; + + // biome-ignore lint/suspicious/noExplicitAny: test mock + const user = getCurrentUser(mockPb as any); + + expect(user).not.toBeNull(); + expect(user?.garminTokenExpiresAt).toBeNull(); + expect(user?.lastPeriodDate).toBeNull(); }); }); diff --git a/src/lib/pocketbase.ts b/src/lib/pocketbase.ts index 20503a0..66ba043 100644 --- a/src/lib/pocketbase.ts +++ b/src/lib/pocketbase.ts @@ -73,6 +73,15 @@ export function loadAuthFromCookies( } } +/** + * Safely parses a date string, returning null for invalid or empty values. + */ +function parseDate(value: unknown): Date | null { + if (!value || typeof value !== "string") return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +} + /** * Maps a PocketBase record to our typed User interface. */ @@ -83,9 +92,9 @@ function mapRecordToUser(record: RecordModel): User { garminConnected: record.garminConnected as boolean, garminOauth1Token: record.garminOauth1Token as string, garminOauth2Token: record.garminOauth2Token as string, - garminTokenExpiresAt: new Date(record.garminTokenExpiresAt as string), + garminTokenExpiresAt: parseDate(record.garminTokenExpiresAt), calendarToken: record.calendarToken as string, - lastPeriodDate: new Date(record.lastPeriodDate as string), + lastPeriodDate: parseDate(record.lastPeriodDate), cycleLength: record.cycleLength as number, notificationTime: record.notificationTime as string, timezone: record.timezone as string, diff --git a/src/types/index.ts b/src/types/index.ts index 9e236f2..f880fc7 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -22,13 +22,13 @@ export interface User { garminConnected: boolean; garminOauth1Token: string; // encrypted JSON garminOauth2Token: string; // encrypted JSON - garminTokenExpiresAt: Date; + garminTokenExpiresAt: Date | null; // Calendar calendarToken: string; // random secret for ICS URL // Cycle - lastPeriodDate: Date; + lastPeriodDate: Date | null; cycleLength: number; // default: 31 // Preferences