Fix Invalid Date error in auth middleware
All checks were successful
Deploy / deploy (push) Successful in 2m28s
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:
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user