From 2408839b8b55698a7a83cac9fe4e0a787cabdcfd Mon Sep 17 00:00:00 2001 From: Petru Paler Date: Mon, 12 Jan 2026 16:45:55 +0000 Subject: [PATCH] Fix 404 error when saving user preferences Routes using withAuth were creating new unauthenticated PocketBase clients, causing 404 errors when trying to update records. Modified withAuth to pass the authenticated pb client to handlers so they can use it for database operations. Co-Authored-By: Claude Opus 4.5 --- src/app/api/auth/logout/route.ts | 2 +- .../calendar/regenerate-token/route.test.ts | 14 +++---- .../api/calendar/regenerate-token/route.ts | 4 +- src/app/api/cycle/period/route.test.ts | 18 ++++---- src/app/api/cycle/period/route.ts | 5 +-- src/app/api/garmin/tokens/route.test.ts | 14 +++---- src/app/api/garmin/tokens/route.ts | 8 +--- src/app/api/history/route.test.ts | 14 +++---- src/app/api/history/route.ts | 4 +- src/app/api/overrides/route.test.ts | 42 +++++++++---------- src/app/api/overrides/route.ts | 7 +--- src/app/api/today/route.test.ts | 31 ++++++-------- src/app/api/today/route.ts | 4 +- src/app/api/user/route.test.ts | 14 +++---- src/app/api/user/route.ts | 6 +-- src/lib/auth-middleware.test.ts | 9 +++- src/lib/auth-middleware.ts | 14 ++++--- 17 files changed, 91 insertions(+), 119 deletions(-) diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts index f1849b8..89284b0 100644 --- a/src/app/api/auth/logout/route.ts +++ b/src/app/api/auth/logout/route.ts @@ -12,7 +12,7 @@ import { logger } from "@/lib/logger"; * Clears the user's authentication session by deleting the pb_auth cookie. * Returns a success response with redirect URL. */ -export async function POST(): Promise { +export async function POST(_request: Request): Promise { try { const cookieStore = await cookies(); diff --git a/src/app/api/calendar/regenerate-token/route.test.ts b/src/app/api/calendar/regenerate-token/route.test.ts index 6a69c2e..99866b3 100644 --- a/src/app/api/calendar/regenerate-token/route.test.ts +++ b/src/app/api/calendar/regenerate-token/route.test.ts @@ -12,14 +12,12 @@ let currentMockUser: User | null = null; // Track PocketBase update calls const mockPbUpdate = vi.fn().mockResolvedValue({}); -// Mock PocketBase -vi.mock("@/lib/pocketbase", () => ({ - createPocketBaseClient: vi.fn(() => ({ - collection: vi.fn(() => ({ - update: mockPbUpdate, - })), +// Create mock PocketBase client +const mockPb = { + collection: vi.fn(() => ({ + update: mockPbUpdate, })), -})); +}; // Mock the auth-middleware module vi.mock("@/lib/auth-middleware", () => ({ @@ -28,7 +26,7 @@ vi.mock("@/lib/auth-middleware", () => ({ if (!currentMockUser) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - return handler(request, currentMockUser); + return handler(request, currentMockUser, mockPb); }; }), })); diff --git a/src/app/api/calendar/regenerate-token/route.ts b/src/app/api/calendar/regenerate-token/route.ts index 6ce4769..dcfea1a 100644 --- a/src/app/api/calendar/regenerate-token/route.ts +++ b/src/app/api/calendar/regenerate-token/route.ts @@ -5,7 +5,6 @@ import { randomBytes } from "node:crypto"; import { NextResponse } from "next/server"; import { withAuth } from "@/lib/auth-middleware"; -import { createPocketBaseClient } from "@/lib/pocketbase"; /** * Generates a cryptographically secure random 32-character alphanumeric token. @@ -17,12 +16,11 @@ function generateToken(): string { return randomBytes(32).toString("hex").slice(0, 32); } -export const POST = withAuth(async (_request, user) => { +export const POST = withAuth(async (_request, user, pb) => { // Generate new random token const newToken = generateToken(); // Update user record with new token - const pb = createPocketBaseClient(); await pb.collection("users").update(user.id, { calendarToken: newToken, }); diff --git a/src/app/api/cycle/period/route.test.ts b/src/app/api/cycle/period/route.test.ts index 74ee215..6ae28ef 100644 --- a/src/app/api/cycle/period/route.test.ts +++ b/src/app/api/cycle/period/route.test.ts @@ -13,17 +13,13 @@ let currentMockUser: User | null = null; const mockPbUpdate = vi.fn(); const mockPbCreate = vi.fn(); -vi.mock("@/lib/pocketbase", () => ({ - createPocketBaseClient: vi.fn(() => ({ - collection: vi.fn((_name: string) => ({ - update: mockPbUpdate, - create: mockPbCreate, - })), +// Create mock PocketBase client +const mockPb = { + collection: vi.fn((_name: string) => ({ + update: mockPbUpdate, + create: mockPbCreate, })), - loadAuthFromCookies: vi.fn(), - isAuthenticated: vi.fn(() => currentMockUser !== null), - getCurrentUser: vi.fn(() => currentMockUser), -})); +}; // Mock the auth-middleware module vi.mock("@/lib/auth-middleware", () => ({ @@ -32,7 +28,7 @@ vi.mock("@/lib/auth-middleware", () => ({ if (!currentMockUser) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - return handler(request, currentMockUser); + return handler(request, currentMockUser, mockPb); }; }), })); diff --git a/src/app/api/cycle/period/route.ts b/src/app/api/cycle/period/route.ts index 38f8090..3654d59 100644 --- a/src/app/api/cycle/period/route.ts +++ b/src/app/api/cycle/period/route.ts @@ -6,7 +6,6 @@ import { NextResponse } from "next/server"; import { withAuth } from "@/lib/auth-middleware"; import { getCycleDay, getPhase } from "@/lib/cycle"; import { logger } from "@/lib/logger"; -import { createPocketBaseClient } from "@/lib/pocketbase"; interface PeriodLogRequest { startDate?: string; @@ -35,7 +34,7 @@ function isFutureDate(dateStr: string): boolean { return inputDate > today; } -export const POST = withAuth(async (request: NextRequest, user) => { +export const POST = withAuth(async (request: NextRequest, user, pb) => { try { const body = (await request.json()) as PeriodLogRequest; @@ -63,8 +62,6 @@ export const POST = withAuth(async (request: NextRequest, user) => { ); } - const pb = createPocketBaseClient(); - // Calculate predicted date based on previous cycle (if exists) let predictedDateStr: string | null = null; if (user.lastPeriodDate) { diff --git a/src/app/api/garmin/tokens/route.test.ts b/src/app/api/garmin/tokens/route.test.ts index fbf6e74..3dabc7c 100644 --- a/src/app/api/garmin/tokens/route.test.ts +++ b/src/app/api/garmin/tokens/route.test.ts @@ -12,14 +12,12 @@ let currentMockUser: User | null = null; // Track PocketBase update calls const mockPbUpdate = vi.fn().mockResolvedValue({}); -// Mock PocketBase -vi.mock("@/lib/pocketbase", () => ({ - createPocketBaseClient: vi.fn(() => ({ - collection: vi.fn(() => ({ - update: mockPbUpdate, - })), +// Create mock PocketBase client +const mockPb = { + collection: vi.fn(() => ({ + update: mockPbUpdate, })), -})); +}; // Track encryption calls const mockEncrypt = vi.fn((plaintext: string) => `encrypted:${plaintext}`); @@ -36,7 +34,7 @@ vi.mock("@/lib/auth-middleware", () => ({ if (!currentMockUser) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - return handler(request, currentMockUser); + return handler(request, currentMockUser, mockPb); }; }), })); diff --git a/src/app/api/garmin/tokens/route.ts b/src/app/api/garmin/tokens/route.ts index b5fb6dd..a773915 100644 --- a/src/app/api/garmin/tokens/route.ts +++ b/src/app/api/garmin/tokens/route.ts @@ -5,9 +5,8 @@ import { NextResponse } from "next/server"; import { withAuth } from "@/lib/auth-middleware"; import { encrypt } from "@/lib/encryption"; import { daysUntilExpiry } from "@/lib/garmin"; -import { createPocketBaseClient } from "@/lib/pocketbase"; -export const POST = withAuth(async (request, user) => { +export const POST = withAuth(async (request, user, pb) => { const body = await request.json(); const { oauth1, oauth2, expires_at } = body; @@ -57,7 +56,6 @@ export const POST = withAuth(async (request, user) => { const encryptedOauth2 = encrypt(JSON.stringify(oauth2)); // Update user record - const pb = createPocketBaseClient(); await pb.collection("users").update(user.id, { garminOauth1Token: encryptedOauth1, garminOauth2Token: encryptedOauth2, @@ -79,9 +77,7 @@ export const POST = withAuth(async (request, user) => { }); }); -export const DELETE = withAuth(async (_request, user) => { - const pb = createPocketBaseClient(); - +export const DELETE = withAuth(async (_request, user, pb) => { await pb.collection("users").update(user.id, { garminOauth1Token: "", garminOauth2Token: "", diff --git a/src/app/api/history/route.test.ts b/src/app/api/history/route.test.ts index 0016393..eac9b01 100644 --- a/src/app/api/history/route.test.ts +++ b/src/app/api/history/route.test.ts @@ -12,14 +12,12 @@ let currentMockUser: User | null = null; // Track PocketBase collection calls const mockGetList = vi.fn(); -// Mock PocketBase -vi.mock("@/lib/pocketbase", () => ({ - createPocketBaseClient: vi.fn(() => ({ - collection: vi.fn(() => ({ - getList: mockGetList, - })), +// Create mock PocketBase client +const mockPb = { + collection: vi.fn(() => ({ + getList: mockGetList, })), -})); +}; // Mock the auth-middleware module vi.mock("@/lib/auth-middleware", () => ({ @@ -28,7 +26,7 @@ vi.mock("@/lib/auth-middleware", () => ({ if (!currentMockUser) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - return handler(request, currentMockUser); + return handler(request, currentMockUser, mockPb); }; }), })); diff --git a/src/app/api/history/route.ts b/src/app/api/history/route.ts index a7fba8f..88d7c66 100644 --- a/src/app/api/history/route.ts +++ b/src/app/api/history/route.ts @@ -3,7 +3,6 @@ import { NextResponse } from "next/server"; import { withAuth } from "@/lib/auth-middleware"; -import { createPocketBaseClient } from "@/lib/pocketbase"; import type { DailyLog } from "@/types"; // Validation constants @@ -24,7 +23,7 @@ function isValidDateFormat(dateStr: string): boolean { return !Number.isNaN(date.getTime()); } -export const GET = withAuth(async (request, user) => { +export const GET = withAuth(async (request, user, pb) => { const { searchParams } = request.nextUrl; // Parse and validate page parameter @@ -77,7 +76,6 @@ export const GET = withAuth(async (request, user) => { const filter = filters.join(" && "); // Query PocketBase - const pb = createPocketBaseClient(); const result = await pb .collection("dailyLogs") .getList(page, limit, { diff --git a/src/app/api/overrides/route.test.ts b/src/app/api/overrides/route.test.ts index 05c8f8e..3266548 100644 --- a/src/app/api/overrides/route.test.ts +++ b/src/app/api/overrides/route.test.ts @@ -14,6 +14,25 @@ let lastUpdateCall: { data: { activeOverrides: OverrideType[] }; } | null = null; +// Create mock PocketBase client +const mockPb = { + collection: vi.fn(() => ({ + update: vi.fn( + async (id: string, data: { activeOverrides: OverrideType[] }) => { + lastUpdateCall = { id, data }; + // Update the mock user to simulate DB update + if (currentMockUser) { + currentMockUser = { + ...currentMockUser, + activeOverrides: data.activeOverrides, + }; + } + return { ...currentMockUser, ...data }; + }, + ), + })), +}; + // Mock the auth-middleware module vi.mock("@/lib/auth-middleware", () => ({ withAuth: vi.fn((handler) => { @@ -21,32 +40,11 @@ vi.mock("@/lib/auth-middleware", () => ({ if (!currentMockUser) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - return handler(request, currentMockUser); + return handler(request, currentMockUser, mockPb); }; }), })); -// Mock the pocketbase module -vi.mock("@/lib/pocketbase", () => ({ - createPocketBaseClient: vi.fn(() => ({ - collection: vi.fn(() => ({ - update: vi.fn( - async (id: string, data: { activeOverrides: OverrideType[] }) => { - lastUpdateCall = { id, data }; - // Update the mock user to simulate DB update - if (currentMockUser) { - currentMockUser = { - ...currentMockUser, - activeOverrides: data.activeOverrides, - }; - } - return { ...currentMockUser, ...data }; - }, - ), - })), - })), -})); - import { DELETE, POST } from "./route"; describe("POST /api/overrides", () => { diff --git a/src/app/api/overrides/route.ts b/src/app/api/overrides/route.ts index 0af9f49..4aeb421 100644 --- a/src/app/api/overrides/route.ts +++ b/src/app/api/overrides/route.ts @@ -5,7 +5,6 @@ import { NextResponse } from "next/server"; import { withAuth } from "@/lib/auth-middleware"; import { logger } from "@/lib/logger"; -import { createPocketBaseClient } from "@/lib/pocketbase"; import type { OverrideType } from "@/types"; const VALID_OVERRIDE_TYPES: OverrideType[] = [ @@ -27,7 +26,7 @@ function isValidOverrideType(value: unknown): value is OverrideType { * Request body: { override: OverrideType } * Response: { activeOverrides: OverrideType[] } */ -export const POST = withAuth(async (request: NextRequest, user) => { +export const POST = withAuth(async (request: NextRequest, user, pb) => { const body = await request.json(); if (!body.override) { @@ -55,7 +54,6 @@ export const POST = withAuth(async (request: NextRequest, user) => { : [...currentOverrides, overrideToAdd]; // Update the user record in PocketBase - const pb = createPocketBaseClient(); await pb .collection("users") .update(user.id, { activeOverrides: newOverrides }); @@ -74,7 +72,7 @@ export const POST = withAuth(async (request: NextRequest, user) => { * Request body: { override: OverrideType } * Response: { activeOverrides: OverrideType[] } */ -export const DELETE = withAuth(async (request: NextRequest, user) => { +export const DELETE = withAuth(async (request: NextRequest, user, pb) => { const body = await request.json(); if (!body.override) { @@ -100,7 +98,6 @@ export const DELETE = withAuth(async (request: NextRequest, user) => { const newOverrides = currentOverrides.filter((o) => o !== overrideToRemove); // Update the user record in PocketBase - const pb = createPocketBaseClient(); await pb .collection("users") .update(user.id, { activeOverrides: newOverrides }); diff --git a/src/app/api/today/route.test.ts b/src/app/api/today/route.test.ts index 9a21550..52c266f 100644 --- a/src/app/api/today/route.test.ts +++ b/src/app/api/today/route.test.ts @@ -12,24 +12,19 @@ let currentMockUser: User | null = null; // Module-level variable to control mock daily log in tests let currentMockDailyLog: DailyLog | null = null; -// Mock PocketBase client for database operations -vi.mock("@/lib/pocketbase", () => ({ - createPocketBaseClient: vi.fn(() => ({ - collection: vi.fn(() => ({ - getFirstListItem: vi.fn(async () => { - if (!currentMockDailyLog) { - const error = new Error("No DailyLog found"); - (error as { status?: number }).status = 404; - throw error; - } - return currentMockDailyLog; - }), - })), +// Create mock PocketBase client +const mockPb = { + collection: vi.fn(() => ({ + getFirstListItem: vi.fn(async () => { + if (!currentMockDailyLog) { + const error = new Error("No DailyLog found"); + (error as { status?: number }).status = 404; + throw error; + } + return currentMockDailyLog; + }), })), - loadAuthFromCookies: vi.fn(), - isAuthenticated: vi.fn(() => currentMockUser !== null), - getCurrentUser: vi.fn(() => currentMockUser), -})); +}; // Mock the auth-middleware module vi.mock("@/lib/auth-middleware", () => ({ @@ -38,7 +33,7 @@ vi.mock("@/lib/auth-middleware", () => ({ if (!currentMockUser) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - return handler(request, currentMockUser); + return handler(request, currentMockUser, mockPb); }; }), })); diff --git a/src/app/api/today/route.ts b/src/app/api/today/route.ts index 17567f6..8f36416 100644 --- a/src/app/api/today/route.ts +++ b/src/app/api/today/route.ts @@ -12,7 +12,6 @@ import { import { getDecisionWithOverrides } from "@/lib/decision-engine"; import { logger } from "@/lib/logger"; import { getNutritionGuidance } from "@/lib/nutrition"; -import { createPocketBaseClient } from "@/lib/pocketbase"; import type { DailyData, DailyLog, HrvStatus } from "@/types"; // Default biometrics when no Garmin data is available @@ -28,7 +27,7 @@ const DEFAULT_BIOMETRICS: { weekIntensityMinutes: 0, }; -export const GET = withAuth(async (_request, user) => { +export const GET = withAuth(async (_request, user, pb) => { // Validate required user data if (!user.lastPeriodDate) { return NextResponse.json( @@ -70,7 +69,6 @@ export const GET = withAuth(async (_request, user) => { // Try to fetch today's DailyLog for biometrics let biometrics = { ...DEFAULT_BIOMETRICS, phaseLimit }; try { - const pb = createPocketBaseClient(); const today = new Date().toISOString().split("T")[0]; const dailyLog = await pb .collection("dailyLogs") diff --git a/src/app/api/user/route.test.ts b/src/app/api/user/route.test.ts index a672927..60b8d0f 100644 --- a/src/app/api/user/route.test.ts +++ b/src/app/api/user/route.test.ts @@ -12,14 +12,12 @@ let currentMockUser: User | null = null; // Track PocketBase update calls const mockPbUpdate = vi.fn().mockResolvedValue({}); -// Mock PocketBase -vi.mock("@/lib/pocketbase", () => ({ - createPocketBaseClient: vi.fn(() => ({ - collection: vi.fn(() => ({ - update: mockPbUpdate, - })), +// Create mock PocketBase client +const mockPb = { + collection: vi.fn(() => ({ + update: mockPbUpdate, })), -})); +}; // Mock the auth-middleware module vi.mock("@/lib/auth-middleware", () => ({ @@ -28,7 +26,7 @@ vi.mock("@/lib/auth-middleware", () => ({ if (!currentMockUser) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - return handler(request, currentMockUser); + return handler(request, currentMockUser, mockPb); }; }), })); diff --git a/src/app/api/user/route.ts b/src/app/api/user/route.ts index 0e10274..0a56fa7 100644 --- a/src/app/api/user/route.ts +++ b/src/app/api/user/route.ts @@ -4,7 +4,6 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { withAuth } from "@/lib/auth-middleware"; -import { createPocketBaseClient } from "@/lib/pocketbase"; // Validation constants const CYCLE_LENGTH_MIN = 21; @@ -16,7 +15,7 @@ const TIME_FORMAT_REGEX = /^([01]\d|2[0-3]):([0-5]\d)$/; * Returns the authenticated user's profile. * Excludes sensitive fields like encrypted tokens. */ -export const GET = withAuth(async (_request, user) => { +export const GET = withAuth(async (_request, user, _pb) => { // Format date for consistent API response const lastPeriodDate = user.lastPeriodDate ? user.lastPeriodDate.toISOString().split("T")[0] @@ -81,7 +80,7 @@ function validateTimezone(value: unknown): string | null { * Updates the authenticated user's profile. * Allowed fields: cycleLength, notificationTime, timezone */ -export const PATCH = withAuth(async (request: NextRequest, user) => { +export const PATCH = withAuth(async (request: NextRequest, user, pb) => { const body = await request.json(); // Build update object with only valid, updatable fields @@ -132,7 +131,6 @@ export const PATCH = withAuth(async (request: NextRequest, user) => { } // Update the user record in PocketBase - const pb = createPocketBaseClient(); await pb.collection("users").update(user.id, updates); // Build updated user profile for response diff --git a/src/lib/auth-middleware.test.ts b/src/lib/auth-middleware.test.ts index 996d2d0..74ac96c 100644 --- a/src/lib/auth-middleware.test.ts +++ b/src/lib/auth-middleware.test.ts @@ -113,7 +113,12 @@ describe("withAuth", () => { const response = await wrappedHandler(mockRequest); expect(response.status).toBe(200); - expect(handler).toHaveBeenCalledWith(mockRequest, mockUser, undefined); + expect(handler).toHaveBeenCalledWith( + mockRequest, + mockUser, + mockPbClient, + undefined, + ); }); it("loads auth from cookies before checking authentication", async () => { @@ -159,7 +164,7 @@ describe("withAuth", () => { await wrappedHandler(mockRequest, { params: mockParams }); - expect(handler).toHaveBeenCalledWith(mockRequest, mockUser, { + expect(handler).toHaveBeenCalledWith(mockRequest, mockUser, mockPbClient, { params: mockParams, }); }); diff --git a/src/lib/auth-middleware.ts b/src/lib/auth-middleware.ts index 251af17..6f9c305 100644 --- a/src/lib/auth-middleware.ts +++ b/src/lib/auth-middleware.ts @@ -4,6 +4,7 @@ import { cookies } from "next/headers"; import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; +import type PocketBase from "pocketbase"; import type { User } from "@/types"; @@ -16,24 +17,27 @@ import { } from "./pocketbase"; /** - * Route handler function type that receives the authenticated user. + * Route handler function type that receives the authenticated user and PocketBase client. */ export type AuthenticatedHandler = ( request: NextRequest, user: User, + pb: PocketBase, context?: { params?: T }, ) => Promise; /** * Higher-order function that wraps an API route handler with authentication. - * Loads auth from cookies, validates the session, and passes the user to the handler. + * Loads auth from cookies, validates the session, and passes the user and + * authenticated PocketBase client to the handler. * * @param handler - The route handler that requires authentication * @returns A wrapped handler that checks auth before calling the original handler * * @example * ```ts - * export const GET = withAuth(async (request, user) => { + * export const GET = withAuth(async (request, user, pb) => { + * const data = await pb.collection("users").getOne(user.id); * return NextResponse.json({ email: user.email }); * }); * ``` @@ -66,8 +70,8 @@ export function withAuth( return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - // Call the original handler with the user context - return await handler(request, user, context); + // Call the original handler with the user context and authenticated pb client + return await handler(request, user, pb, context); } catch (error) { logger.error({ err: error }, "Auth middleware error"); return NextResponse.json(