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:
@@ -26,7 +26,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
|
|||||||
| Route | Status | Notes |
|
| Route | Status | Notes |
|
||||||
|-------|--------|-------|
|
|-------|--------|-------|
|
||||||
| GET /api/user | **COMPLETE** | Returns user profile with `withAuth()` |
|
| GET /api/user | **COMPLETE** | Returns user profile with `withAuth()` |
|
||||||
| PATCH /api/user | 501 | Returns Not Implemented |
|
| PATCH /api/user | **COMPLETE** | Updates cycleLength, notificationTime, timezone (17 tests) |
|
||||||
| POST /api/cycle/period | **COMPLETE** | Logs period start, updates user, creates PeriodLog (8 tests) |
|
| POST /api/cycle/period | **COMPLETE** | Logs period start, updates user, creates PeriodLog (8 tests) |
|
||||||
| GET /api/cycle/current | **COMPLETE** | Returns cycle day, phase, config, daysUntilNextPhase (10 tests) |
|
| GET /api/cycle/current | **COMPLETE** | Returns cycle day, phase, config, daysUntilNextPhase (10 tests) |
|
||||||
| GET /api/today | **COMPLETE** | Returns decision, cycle, biometrics, nutrition (22 tests) |
|
| GET /api/today | **COMPLETE** | Returns decision, cycle, biometrics, nutrition (22 tests) |
|
||||||
@@ -70,7 +70,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
|
|||||||
| `src/lib/pocketbase.test.ts` | **EXISTS** - 9 tests (auth helpers, cookie loading) |
|
| `src/lib/pocketbase.test.ts` | **EXISTS** - 9 tests (auth helpers, cookie loading) |
|
||||||
| `src/lib/auth-middleware.test.ts` | **EXISTS** - 6 tests (withAuth wrapper, error handling) |
|
| `src/lib/auth-middleware.test.ts` | **EXISTS** - 6 tests (withAuth wrapper, error handling) |
|
||||||
| `src/middleware.test.ts` | **EXISTS** - 12 tests (page protection, public routes, static assets) |
|
| `src/middleware.test.ts` | **EXISTS** - 12 tests (page protection, public routes, static assets) |
|
||||||
| `src/app/api/user/route.test.ts` | **EXISTS** - 4 tests (GET profile, auth, sensitive field exclusion) |
|
| `src/app/api/user/route.test.ts` | **EXISTS** - 21 tests (GET/PATCH profile, auth, validation, security) |
|
||||||
| `src/app/api/cycle/period/route.test.ts` | **EXISTS** - 8 tests (POST period, auth, validation, date checks) |
|
| `src/app/api/cycle/period/route.test.ts` | **EXISTS** - 8 tests (POST period, auth, validation, date checks) |
|
||||||
| `src/app/api/cycle/current/route.test.ts` | **EXISTS** - 10 tests (GET current cycle, auth, all phases, rollover, custom lengths) |
|
| `src/app/api/cycle/current/route.test.ts` | **EXISTS** - 10 tests (GET current cycle, auth, all phases, rollover, custom lengths) |
|
||||||
| `src/app/api/today/route.test.ts` | **EXISTS** - 22 tests (daily snapshot, auth, decision, overrides, phases, nutrition, biometrics) |
|
| `src/app/api/today/route.test.ts` | **EXISTS** - 22 tests (daily snapshot, auth, decision, overrides, phases, nutrition, biometrics) |
|
||||||
@@ -149,12 +149,17 @@ These must be completed first - nothing else works without them.
|
|||||||
|
|
||||||
Minimum viable product - app can be used for daily decisions.
|
Minimum viable product - app can be used for daily decisions.
|
||||||
|
|
||||||
### P1.1: PATCH /api/user Implementation
|
### P1.1: PATCH /api/user Implementation ✅ COMPLETE
|
||||||
- [ ] Allow profile updates (cycleLength, notificationTime, timezone)
|
- [x] Allow profile updates (cycleLength, notificationTime, timezone)
|
||||||
- **Files:**
|
- **Files:**
|
||||||
- `src/app/api/user/route.ts` - Implement PATCH handler with validation
|
- `src/app/api/user/route.ts` - Implemented PATCH handler with validation
|
||||||
- **Tests:**
|
- **Tests:**
|
||||||
- `src/app/api/user/route.test.ts` - Test field validation, persistence
|
- `src/app/api/user/route.test.ts` - 17 tests covering field validation, persistence, security
|
||||||
|
- **Validation Rules:**
|
||||||
|
- `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)
|
||||||
- **Why:** Users need to configure their cycle and preferences
|
- **Why:** Users need to configure their cycle and preferences
|
||||||
- **Depends On:** P0.1, P0.2
|
- **Depends On:** P0.1, P0.2
|
||||||
|
|
||||||
@@ -495,6 +500,7 @@ P2.14 Mini calendar
|
|||||||
|
|
||||||
### API Routes
|
### API Routes
|
||||||
- [x] **GET /api/user** - Returns authenticated user profile, 4 tests (P0.4)
|
- [x] **GET /api/user** - Returns authenticated user profile, 4 tests (P0.4)
|
||||||
|
- [x] **PATCH /api/user** - Updates user profile (cycleLength, notificationTime, timezone), 17 tests (P1.1)
|
||||||
- [x] **POST /api/cycle/period** - Logs period start date, updates user, creates PeriodLog, 8 tests (P1.2)
|
- [x] **POST /api/cycle/period** - Logs period start date, updates user, creates PeriodLog, 8 tests (P1.2)
|
||||||
- [x] **GET /api/cycle/current** - Returns cycle day, phase, phaseConfig, daysUntilNextPhase, cycleLength, 10 tests (P1.3)
|
- [x] **GET /api/cycle/current** - Returns cycle day, phase, phaseConfig, daysUntilNextPhase, cycleLength, 10 tests (P1.3)
|
||||||
- [x] **GET /api/today** - Returns complete daily snapshot with decision, biometrics, nutrition, 22 tests (P1.4)
|
- [x] **GET /api/today** - Returns complete daily snapshot with decision, biometrics, nutrition, 22 tests (P1.4)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// ABOUTME: Unit tests for user profile API route.
|
// ABOUTME: Unit tests for user profile API route.
|
||||||
// ABOUTME: Tests GET /api/user for profile retrieval with authentication.
|
// ABOUTME: Tests GET and PATCH /api/user for profile retrieval and updates.
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
@@ -9,6 +9,18 @@ import type { User } from "@/types";
|
|||||||
// Module-level variable to control mock user in tests
|
// Module-level variable to control mock user in tests
|
||||||
let currentMockUser: User | null = null;
|
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,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
// Mock the auth-middleware module
|
// Mock the auth-middleware module
|
||||||
vi.mock("@/lib/auth-middleware", () => ({
|
vi.mock("@/lib/auth-middleware", () => ({
|
||||||
withAuth: vi.fn((handler) => {
|
withAuth: vi.fn((handler) => {
|
||||||
@@ -21,7 +33,7 @@ vi.mock("@/lib/auth-middleware", () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { GET } from "./route";
|
import { GET, PATCH } from "./route";
|
||||||
|
|
||||||
describe("GET /api/user", () => {
|
describe("GET /api/user", () => {
|
||||||
const mockUser: User = {
|
const mockUser: User = {
|
||||||
@@ -44,6 +56,7 @@ describe("GET /api/user", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
currentMockUser = null;
|
currentMockUser = null;
|
||||||
|
mockPbUpdate.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns user profile when authenticated", async () => {
|
it("returns user profile when authenticated", async () => {
|
||||||
@@ -99,3 +112,269 @@ describe("GET /api/user", () => {
|
|||||||
expect(body.error).toBe("Unauthorized");
|
expect(body.error).toBe("Unauthorized");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("PATCH /api/user", () => {
|
||||||
|
const mockUser: User = {
|
||||||
|
id: "user123",
|
||||||
|
email: "test@example.com",
|
||||||
|
garminConnected: true,
|
||||||
|
garminOauth1Token: "encrypted-token-1",
|
||||||
|
garminOauth2Token: "encrypted-token-2",
|
||||||
|
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||||
|
calendarToken: "cal-secret-token",
|
||||||
|
lastPeriodDate: new Date("2025-01-15"),
|
||||||
|
cycleLength: 28,
|
||||||
|
notificationTime: "07:30",
|
||||||
|
timezone: "America/New_York",
|
||||||
|
activeOverrides: ["flare"],
|
||||||
|
created: new Date("2024-01-01"),
|
||||||
|
updated: new Date("2025-01-10"),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
currentMockUser = null;
|
||||||
|
mockPbUpdate.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper to create mock request with JSON body
|
||||||
|
function createMockRequest(body: Record<string, unknown>): NextRequest {
|
||||||
|
return {
|
||||||
|
json: vi.fn().mockResolvedValue(body),
|
||||||
|
} as unknown as NextRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("returns 401 when not authenticated", async () => {
|
||||||
|
currentMockUser = null;
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest({ cycleLength: 30 });
|
||||||
|
const response = await PATCH(mockRequest);
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.error).toBe("Unauthorized");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates cycleLength successfully", async () => {
|
||||||
|
currentMockUser = mockUser;
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest({ cycleLength: 30 });
|
||||||
|
const response = await PATCH(mockRequest);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.cycleLength).toBe(30);
|
||||||
|
expect(mockPbUpdate).toHaveBeenCalledWith("user123", { cycleLength: 30 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates notificationTime successfully", async () => {
|
||||||
|
currentMockUser = mockUser;
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest({ notificationTime: "08:00" });
|
||||||
|
const response = await PATCH(mockRequest);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.notificationTime).toBe("08:00");
|
||||||
|
expect(mockPbUpdate).toHaveBeenCalledWith("user123", {
|
||||||
|
notificationTime: "08:00",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates timezone successfully", async () => {
|
||||||
|
currentMockUser = mockUser;
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest({ timezone: "Europe/London" });
|
||||||
|
const response = await PATCH(mockRequest);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.timezone).toBe("Europe/London");
|
||||||
|
expect(mockPbUpdate).toHaveBeenCalledWith("user123", {
|
||||||
|
timezone: "Europe/London",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates multiple fields at once", async () => {
|
||||||
|
currentMockUser = mockUser;
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest({
|
||||||
|
cycleLength: 35,
|
||||||
|
notificationTime: "06:00",
|
||||||
|
timezone: "Asia/Tokyo",
|
||||||
|
});
|
||||||
|
const response = await PATCH(mockRequest);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.cycleLength).toBe(35);
|
||||||
|
expect(body.notificationTime).toBe("06:00");
|
||||||
|
expect(body.timezone).toBe("Asia/Tokyo");
|
||||||
|
expect(mockPbUpdate).toHaveBeenCalledWith("user123", {
|
||||||
|
cycleLength: 35,
|
||||||
|
notificationTime: "06:00",
|
||||||
|
timezone: "Asia/Tokyo",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when cycleLength is below minimum (21)", async () => {
|
||||||
|
currentMockUser = mockUser;
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest({ cycleLength: 20 });
|
||||||
|
const response = await PATCH(mockRequest);
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.error).toContain("cycleLength");
|
||||||
|
expect(mockPbUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when cycleLength is above maximum (45)", async () => {
|
||||||
|
currentMockUser = mockUser;
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest({ cycleLength: 46 });
|
||||||
|
const response = await PATCH(mockRequest);
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.error).toContain("cycleLength");
|
||||||
|
expect(mockPbUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when cycleLength is not a number", async () => {
|
||||||
|
currentMockUser = mockUser;
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest({ cycleLength: "thirty" });
|
||||||
|
const response = await PATCH(mockRequest);
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.error).toContain("cycleLength");
|
||||||
|
expect(mockPbUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when notificationTime has invalid format", async () => {
|
||||||
|
currentMockUser = mockUser;
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest({ notificationTime: "8am" });
|
||||||
|
const response = await PATCH(mockRequest);
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.error).toContain("notificationTime");
|
||||||
|
expect(mockPbUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when notificationTime has invalid hours", async () => {
|
||||||
|
currentMockUser = mockUser;
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest({ notificationTime: "25:00" });
|
||||||
|
const response = await PATCH(mockRequest);
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.error).toContain("notificationTime");
|
||||||
|
expect(mockPbUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when notificationTime has invalid minutes", async () => {
|
||||||
|
currentMockUser = mockUser;
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest({ notificationTime: "12:60" });
|
||||||
|
const response = await PATCH(mockRequest);
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.error).toContain("notificationTime");
|
||||||
|
expect(mockPbUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when timezone is not a string", async () => {
|
||||||
|
currentMockUser = mockUser;
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest({ timezone: 123 });
|
||||||
|
const response = await PATCH(mockRequest);
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.error).toContain("timezone");
|
||||||
|
expect(mockPbUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when timezone is empty string", async () => {
|
||||||
|
currentMockUser = mockUser;
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest({ timezone: "" });
|
||||||
|
const response = await PATCH(mockRequest);
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.error).toContain("timezone");
|
||||||
|
expect(mockPbUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 with empty body", async () => {
|
||||||
|
currentMockUser = mockUser;
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest({});
|
||||||
|
const response = await PATCH(mockRequest);
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.error).toContain("No valid fields");
|
||||||
|
expect(mockPbUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores non-updatable fields like email", async () => {
|
||||||
|
currentMockUser = mockUser;
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest({
|
||||||
|
email: "hacker@evil.com",
|
||||||
|
cycleLength: 30,
|
||||||
|
});
|
||||||
|
const response = await PATCH(mockRequest);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const body = await response.json();
|
||||||
|
// Email should NOT be changed
|
||||||
|
expect(body.email).toBe("test@example.com");
|
||||||
|
// Only cycleLength should be in the update
|
||||||
|
expect(mockPbUpdate).toHaveBeenCalledWith("user123", { cycleLength: 30 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores attempts to update sensitive fields", async () => {
|
||||||
|
currentMockUser = mockUser;
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest({
|
||||||
|
garminOauth1Token: "stolen-token",
|
||||||
|
calendarToken: "new-calendar-token",
|
||||||
|
cycleLength: 30,
|
||||||
|
});
|
||||||
|
const response = await PATCH(mockRequest);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
// Only cycleLength should be in the update
|
||||||
|
expect(mockPbUpdate).toHaveBeenCalledWith("user123", { cycleLength: 30 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns updated user profile after successful update", async () => {
|
||||||
|
currentMockUser = mockUser;
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest({ cycleLength: 32 });
|
||||||
|
const response = await PATCH(mockRequest);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const body = await response.json();
|
||||||
|
|
||||||
|
// Should return full profile with updated value
|
||||||
|
expect(body.id).toBe("user123");
|
||||||
|
expect(body.email).toBe("test@example.com");
|
||||||
|
expect(body.cycleLength).toBe(32);
|
||||||
|
expect(body.notificationTime).toBe("07:30");
|
||||||
|
expect(body.timezone).toBe("America/New_York");
|
||||||
|
// Should not expose sensitive fields
|
||||||
|
expect(body.garminOauth1Token).toBeUndefined();
|
||||||
|
expect(body.garminOauth2Token).toBeUndefined();
|
||||||
|
expect(body.calendarToken).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
// ABOUTME: API route for user profile management.
|
// ABOUTME: API route for user profile management.
|
||||||
// ABOUTME: Handles GET for profile retrieval and PATCH for updates.
|
// ABOUTME: Handles GET for profile retrieval and PATCH for updates.
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
import { withAuth } from "@/lib/auth-middleware";
|
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
|
* GET /api/user
|
||||||
@@ -27,7 +34,126 @@ export const GET = withAuth(async (_request, user) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function PATCH() {
|
/**
|
||||||
// TODO: Implement user profile update
|
* Validates cycleLength field.
|
||||||
return NextResponse.json({ message: "Not implemented" }, { status: 501 });
|
* 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user