Implement PATCH /api/user endpoint (P1.1)

Add profile update functionality with validation for:
- cycleLength: number, range 21-45 days
- notificationTime: string, HH:MM format (24-hour)
- timezone: non-empty string

Security: Ignores attempts to update non-updatable fields (email, tokens).
Returns updated user profile excluding sensitive fields.

17 tests covering validation, persistence, and security scenarios.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-10 19:14:12 +00:00
parent e4d123704d
commit 18c34916ca
3 changed files with 422 additions and 11 deletions

View File

@@ -1,8 +1,15 @@
// 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";
import { createPocketBaseClient } from "@/lib/pocketbase";
// 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
@@ -27,7 +34,126 @@ export const GET = withAuth(async (_request, user) => {
});
});
export async function PATCH() {
// TODO: Implement user profile update
return NextResponse.json({ message: "Not implemented" }, { status: 501 });
/**
* 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) => {
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
const pb = createPocketBaseClient();
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,
});
});