Fix Invalid Date error in auth middleware
All checks were successful
Deploy / deploy (push) Successful in 2m28s

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 <noreply@anthropic.com>
This commit is contained in:
2026-01-12 14:13:02 +00:00
parent 63023550fd
commit 72706bb91b
5 changed files with 60 additions and 9 deletions

View File

@@ -27,7 +27,8 @@ vi.mock("@/lib/pocketbase", () => ({
id: user.id, id: user.id,
email: user.email, email: user.email,
calendarToken: user.calendarToken, calendarToken: user.calendarToken,
lastPeriodDate: user.lastPeriodDate.toISOString(), // biome-ignore lint/style/noNonNullAssertion: mock user has valid date
lastPeriodDate: user.lastPeriodDate!.toISOString(),
cycleLength: user.cycleLength, cycleLength: user.cycleLength,
garminConnected: user.garminConnected, garminConnected: user.garminConnected,
}; };

View File

@@ -54,8 +54,11 @@ export async function POST(request: Request) {
const pb = createPocketBaseClient(); const pb = createPocketBaseClient();
// Fetch all users (we'll filter garminConnected in code to avoid PocketBase query syntax issues) // 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<User>(); const allUsers = await pb.collection("users").getFullList<User>();
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]; const today = new Date().toISOString().split("T")[0];
@@ -64,10 +67,12 @@ export async function POST(request: Request) {
try { try {
// Check if tokens are expired // Check if tokens are expired
// Note: garminTokenExpiresAt and lastPeriodDate are guaranteed non-null by filter above
const tokens: GarminTokens = { const tokens: GarminTokens = {
oauth1: user.garminOauth1Token, oauth1: user.garminOauth1Token,
oauth2: user.garminOauth2Token, oauth2: user.garminOauth2Token,
expires_at: user.garminTokenExpiresAt.toISOString(), // biome-ignore lint/style/noNonNullAssertion: filtered above
expires_at: user.garminTokenExpiresAt!.toISOString(),
}; };
if (isTokenExpired(tokens)) { if (isTokenExpired(tokens)) {
@@ -101,9 +106,10 @@ export async function POST(request: Request) {
fetchIntensityMinutes(accessToken), fetchIntensityMinutes(accessToken),
]); ]);
// Calculate cycle info // Calculate cycle info (lastPeriodDate guaranteed non-null by filter above)
const cycleDay = getCycleDay( const cycleDay = getCycleDay(
user.lastPeriodDate, // biome-ignore lint/style/noNonNullAssertion: filtered above
user.lastPeriodDate!,
user.cycleLength, user.cycleLength,
new Date(), new Date(),
); );

View File

@@ -127,6 +127,41 @@ describe("getCurrentUser", () => {
expect(user).not.toBeNull(); expect(user).not.toBeNull();
expect(user?.activeOverrides).toEqual([]); 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();
}); });
}); });

View File

@@ -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. * Maps a PocketBase record to our typed User interface.
*/ */
@@ -83,9 +92,9 @@ function mapRecordToUser(record: RecordModel): User {
garminConnected: record.garminConnected as boolean, garminConnected: record.garminConnected as boolean,
garminOauth1Token: record.garminOauth1Token as string, garminOauth1Token: record.garminOauth1Token as string,
garminOauth2Token: record.garminOauth2Token as string, garminOauth2Token: record.garminOauth2Token as string,
garminTokenExpiresAt: new Date(record.garminTokenExpiresAt as string), garminTokenExpiresAt: parseDate(record.garminTokenExpiresAt),
calendarToken: record.calendarToken as string, calendarToken: record.calendarToken as string,
lastPeriodDate: new Date(record.lastPeriodDate as string), lastPeriodDate: parseDate(record.lastPeriodDate),
cycleLength: record.cycleLength as number, cycleLength: record.cycleLength as number,
notificationTime: record.notificationTime as string, notificationTime: record.notificationTime as string,
timezone: record.timezone as string, timezone: record.timezone as string,

View File

@@ -22,13 +22,13 @@ export interface User {
garminConnected: boolean; garminConnected: boolean;
garminOauth1Token: string; // encrypted JSON garminOauth1Token: string; // encrypted JSON
garminOauth2Token: string; // encrypted JSON garminOauth2Token: string; // encrypted JSON
garminTokenExpiresAt: Date; garminTokenExpiresAt: Date | null;
// Calendar // Calendar
calendarToken: string; // random secret for ICS URL calendarToken: string; // random secret for ICS URL
// Cycle // Cycle
lastPeriodDate: Date; lastPeriodDate: Date | null;
cycleLength: number; // default: 31 cycleLength: number; // default: 31
// Preferences // Preferences