// ABOUTME: API route for user profile management. // ABOUTME: Handles GET for profile retrieval and PATCH for updates. import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { withAuth } from "@/lib/auth-middleware"; // Validation constants const CYCLE_LENGTH_MIN = 21; const CYCLE_LENGTH_MAX = 45; const TIME_FORMAT_REGEX = /^([01]\d|2[0-3]):([0-5]\d)$/; /** * GET /api/user * Returns the authenticated user's profile. * Fetches fresh data from database to ensure updates are reflected. * Excludes sensitive fields like encrypted tokens. */ export const GET = withAuth(async (_request, user, pb) => { // Fetch fresh user data from database to get latest values // The user param from withAuth is from auth store cache which may be stale const freshUser = await pb.collection("users").getOne(user.id); // Format date for consistent API response const lastPeriodDate = freshUser.lastPeriodDate ? new Date(freshUser.lastPeriodDate as string).toISOString().split("T")[0] : null; return NextResponse.json( { id: freshUser.id, email: freshUser.email, garminConnected: freshUser.garminConnected ?? false, cycleLength: freshUser.cycleLength, lastPeriodDate, notificationTime: freshUser.notificationTime, timezone: freshUser.timezone, activeOverrides: freshUser.activeOverrides ?? [], calendarToken: (freshUser.calendarToken as string) || null, }, { headers: { "Cache-Control": "no-store, no-cache, must-revalidate" }, }, ); }); /** * Validates cycleLength field. * Must be a number between CYCLE_LENGTH_MIN and CYCLE_LENGTH_MAX. */ function validateCycleLength(value: unknown): string | null { if (typeof value !== "number" || Number.isNaN(value)) { return "cycleLength must be a number"; } if (value < CYCLE_LENGTH_MIN || value > CYCLE_LENGTH_MAX) { return `cycleLength must be between ${CYCLE_LENGTH_MIN} and ${CYCLE_LENGTH_MAX}`; } return null; } /** * Validates notificationTime field. * Must be in HH:MM format (24-hour). */ function validateNotificationTime(value: unknown): string | null { if (typeof value !== "string") { return "notificationTime must be a string in HH:MM format"; } if (!TIME_FORMAT_REGEX.test(value)) { return "notificationTime must be in HH:MM format (00:00-23:59)"; } return null; } /** * Validates timezone field. * Must be a non-empty string. */ function validateTimezone(value: unknown): string | null { if (typeof value !== "string") { return "timezone must be a string"; } if (value.trim() === "") { return "timezone cannot be empty"; } return null; } /** * PATCH /api/user * Updates the authenticated user's profile. * Allowed fields: cycleLength, notificationTime, timezone */ export const PATCH = withAuth(async (request: NextRequest, user, pb) => { const body = await request.json(); // Build update object with only valid, updatable fields const updates: Record = {}; const errors: string[] = []; // Validate and collect cycleLength if (body.cycleLength !== undefined) { const error = validateCycleLength(body.cycleLength); if (error) { errors.push(error); } else { updates.cycleLength = body.cycleLength; } } // Validate and collect notificationTime if (body.notificationTime !== undefined) { const error = validateNotificationTime(body.notificationTime); if (error) { errors.push(error); } else { updates.notificationTime = body.notificationTime; } } // Validate and collect timezone if (body.timezone !== undefined) { const error = validateTimezone(body.timezone); if (error) { errors.push(error); } else { updates.timezone = body.timezone; } } // Return validation errors if any if (errors.length > 0) { return NextResponse.json({ error: errors.join("; ") }, { status: 400 }); } // Check if there are any fields to update if (Object.keys(updates).length === 0) { return NextResponse.json( { error: "No valid fields to update" }, { status: 400 }, ); } // Update the user record in PocketBase await pb.collection("users").update(user.id, updates); // Build updated user profile for response const updatedUser = { ...user, ...updates, }; // Format date for consistent API response const lastPeriodDate = updatedUser.lastPeriodDate ? updatedUser.lastPeriodDate.toISOString().split("T")[0] : null; return NextResponse.json({ id: updatedUser.id, email: updatedUser.email, garminConnected: updatedUser.garminConnected, cycleLength: updatedUser.cycleLength, lastPeriodDate, notificationTime: updatedUser.notificationTime, timezone: updatedUser.timezone, activeOverrides: updatedUser.activeOverrides, }); });