Creates test infrastructure to enable previously skipped e2e tests: - Onboarding user (no period data) for setup flow tests - Established user (period 14 days ago) for normal usage tests - Calendar user (with calendarToken) for ICS feed tests - Garmin user (valid tokens) for connected state tests - Garmin expired user (expired tokens) for expiry warning tests Also fixes ICS feed route to strip .ics suffix from Next.js dynamic route param, adds calendarToken to /api/user response, and sets viewRule on users collection for unauthenticated ICS access. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
169 lines
4.8 KiB
TypeScript
169 lines
4.8 KiB
TypeScript
// 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<string, unknown> = {};
|
|
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,
|
|
});
|
|
});
|