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 <noreply@anthropic.com>
This commit is contained in:
2026-01-12 16:45:55 +00:00
parent df2f52ad50
commit 2408839b8b
17 changed files with 91 additions and 119 deletions

View File

@@ -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<NextResponse> {
export async function POST(_request: Request): Promise<NextResponse> {
try {
const cookieStore = await cookies();

View File

@@ -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);
};
}),
}));

View File

@@ -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,
});

View File

@@ -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);
};
}),
}));

View File

@@ -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) {

View File

@@ -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);
};
}),
}));

View File

@@ -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: "",

View File

@@ -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);
};
}),
}));

View File

@@ -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<DailyLog>(page, limit, {

View File

@@ -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", () => {

View File

@@ -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 });

View File

@@ -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);
};
}),
}));

View File

@@ -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")

View File

@@ -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);
};
}),
}));

View File

@@ -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

View File

@@ -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,
});
});

View File

@@ -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<T = unknown> = (
request: NextRequest,
user: User,
pb: PocketBase,
context?: { params?: T },
) => Promise<NextResponse>;
/**
* 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<T = unknown>(
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(