Implement POST /api/cycle/period endpoint (P1.2)

Add period logging endpoint that allows users to record their period start date.
This is a critical path item that unblocks GET /api/cycle/current and GET /api/today.

Features:
- Protected with withAuth middleware
- Validates startDate is present, valid format (YYYY-MM-DD), and not in future
- Updates user.lastPeriodDate in PocketBase
- Creates PeriodLog record for historical tracking
- Returns updated cycle information (cycleDay, phase)

Tests: 8 tests covering authentication, validation, database operations, and error handling.

🤖 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 18:53:34 +00:00
parent d3ba01d1e1
commit 62ad2e3d1a
3 changed files with 306 additions and 8 deletions

View File

@@ -27,7 +27,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
|-------|--------|-------| |-------|--------|-------|
| 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 | 501 | Returns Not Implemented |
| POST /api/cycle/period | 501 | Returns Not Implemented | | POST /api/cycle/period | **COMPLETE** | Logs period start, updates user, creates PeriodLog (8 tests) |
| GET /api/cycle/current | 501 | Returns Not Implemented | | GET /api/cycle/current | 501 | Returns Not Implemented |
| GET /api/today | 501 | Returns Not Implemented | | GET /api/today | 501 | Returns Not Implemented |
| POST /api/overrides | 501 | Returns Not Implemented | | POST /api/overrides | 501 | Returns Not Implemented |
@@ -71,6 +71,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
| `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** - 4 tests (GET profile, auth, sensitive field exclusion) |
| `src/app/api/cycle/period/route.test.ts` | **EXISTS** - 8 tests (POST period, auth, validation, date checks) |
| `src/lib/nutrition.test.ts` | **MISSING** | | `src/lib/nutrition.test.ts` | **MISSING** |
| `src/lib/email.test.ts` | **MISSING** | | `src/lib/email.test.ts` | **MISSING** |
| `src/lib/ics.test.ts` | **MISSING** | | `src/lib/ics.test.ts` | **MISSING** |
@@ -154,12 +155,12 @@ Minimum viable product - app can be used for daily decisions.
- **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
### P1.2: POST /api/cycle/period Implementation ### P1.2: POST /api/cycle/period Implementation ✅ COMPLETE
- [ ] Log period start date, update user record, create PeriodLog - [x] Log period start date, update user record, create PeriodLog
- **Files:** - **Files:**
- `src/app/api/cycle/period/route.ts` - Implement POST handler - `src/app/api/cycle/period/route.ts` - Implemented POST handler with validation
- **Tests:** - **Tests:**
- `src/app/api/cycle/period/route.test.ts` - Test date validation, user update, log creation - `src/app/api/cycle/period/route.test.ts` - 8 tests covering auth, date validation, user update, PeriodLog creation
- **Why:** Cycle tracking is the foundation of all recommendations - **Why:** Cycle tracking is the foundation of all recommendations
- **Depends On:** P0.1, P0.2 - **Depends On:** P0.1, P0.2
@@ -481,6 +482,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] **POST /api/cycle/period** - Logs period start date, updates user, creates PeriodLog, 8 tests (P1.2)
--- ---

View File

@@ -0,0 +1,208 @@
// ABOUTME: Unit tests for period logging API route.
// ABOUTME: Tests POST /api/cycle/period for period start date logging.
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { User } from "@/types";
// Module-level variable to control mock user in tests
let currentMockUser: User | null = null;
// Mock PocketBase client for database operations
const mockPbUpdate = vi.fn();
const mockPbCreate = vi.fn();
vi.mock("@/lib/pocketbase", () => ({
createPocketBaseClient: vi.fn(() => ({
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", () => ({
withAuth: vi.fn((handler) => {
return async (request: NextRequest) => {
if (!currentMockUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return handler(request, currentMockUser);
};
}),
}));
import { POST } from "./route";
describe("POST /api/cycle/period", () => {
const mockUser: User = {
id: "user123",
email: "test@example.com",
garminConnected: false,
garminOauth1Token: "",
garminOauth2Token: "",
garminTokenExpiresAt: new Date("2025-06-01"),
calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2024-12-15"),
cycleLength: 28,
notificationTime: "07:00",
timezone: "America/New_York",
activeOverrides: [],
created: new Date("2024-01-01"),
updated: new Date("2025-01-10"),
};
beforeEach(() => {
vi.clearAllMocks();
currentMockUser = null;
mockPbUpdate.mockResolvedValue({});
mockPbCreate.mockResolvedValue({ id: "periodlog123" });
});
it("returns 401 when not authenticated", async () => {
currentMockUser = null;
const mockRequest = {
json: vi.fn().mockResolvedValue({ startDate: "2025-01-10" }),
} as unknown as NextRequest;
const response = await POST(mockRequest);
expect(response.status).toBe(401);
const body = await response.json();
expect(body.error).toBe("Unauthorized");
});
it("returns 400 when startDate is missing", async () => {
currentMockUser = mockUser;
const mockRequest = {
json: vi.fn().mockResolvedValue({}),
} as unknown as NextRequest;
const response = await POST(mockRequest);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("startDate");
});
it("returns 400 when startDate is invalid format", async () => {
currentMockUser = mockUser;
const mockRequest = {
json: vi.fn().mockResolvedValue({ startDate: "invalid-date" }),
} as unknown as NextRequest;
const response = await POST(mockRequest);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("Invalid");
});
it("returns 400 when startDate is in the future", async () => {
currentMockUser = mockUser;
// Set a date far in the future
const futureDate = new Date();
futureDate.setFullYear(futureDate.getFullYear() + 1);
const futureDateStr = futureDate.toISOString().split("T")[0];
const mockRequest = {
json: vi.fn().mockResolvedValue({ startDate: futureDateStr }),
} as unknown as NextRequest;
const response = await POST(mockRequest);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("future");
});
it("updates user lastPeriodDate and creates PeriodLog", async () => {
currentMockUser = mockUser;
const mockRequest = {
json: vi.fn().mockResolvedValue({ startDate: "2025-01-10" }),
} as unknown as NextRequest;
const response = await POST(mockRequest);
expect(response.status).toBe(200);
// Verify user record was updated
expect(mockPbUpdate).toHaveBeenCalledWith(
"user123",
expect.objectContaining({
lastPeriodDate: "2025-01-10",
}),
);
// Verify PeriodLog was created
expect(mockPbCreate).toHaveBeenCalledWith(
expect.objectContaining({
user: "user123",
startDate: "2025-01-10",
}),
);
});
it("returns updated cycle information", async () => {
currentMockUser = mockUser;
const mockRequest = {
json: vi.fn().mockResolvedValue({ startDate: "2025-01-10" }),
} as unknown as NextRequest;
const response = await POST(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
// Should return cycle info
expect(body.lastPeriodDate).toBe("2025-01-10");
expect(body.cycleDay).toBeTypeOf("number");
expect(body.phase).toBeTypeOf("string");
expect(body.message).toBe("Period start date logged successfully");
});
it("calculates correct cycle day for new period", async () => {
currentMockUser = mockUser;
// If period started today, cycle day should be 1
const today = new Date().toISOString().split("T")[0];
const mockRequest = {
json: vi.fn().mockResolvedValue({ startDate: today }),
} as unknown as NextRequest;
const response = await POST(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.cycleDay).toBe(1);
expect(body.phase).toBe("MENSTRUAL");
});
it("handles database update errors gracefully", async () => {
currentMockUser = mockUser;
mockPbUpdate.mockRejectedValue(new Error("Database error"));
const mockRequest = {
json: vi.fn().mockResolvedValue({ startDate: "2025-01-10" }),
} as unknown as NextRequest;
const response = await POST(mockRequest);
expect(response.status).toBe(500);
const body = await response.json();
expect(body.error).toBe("Failed to update period date");
});
});

View File

@@ -1,8 +1,96 @@
// ABOUTME: API route for logging period start dates. // ABOUTME: API route for logging period start dates.
// ABOUTME: Recalculates all phase dates when period is logged. // ABOUTME: Recalculates all phase dates when period is logged.
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
export async function POST() { import { withAuth } from "@/lib/auth-middleware";
// TODO: Implement period logging import { getCycleDay, getPhase } from "@/lib/cycle";
return NextResponse.json({ message: "Not implemented" }, { status: 501 }); import { createPocketBaseClient } from "@/lib/pocketbase";
interface PeriodLogRequest {
startDate?: string;
} }
/**
* Validates a date string is in YYYY-MM-DD format and represents a valid date.
*/
function isValidDateFormat(dateStr: string): boolean {
const regex = /^\d{4}-\d{2}-\d{2}$/;
if (!regex.test(dateStr)) {
return false;
}
const date = new Date(dateStr);
return !Number.isNaN(date.getTime());
}
/**
* Checks if a date is in the future (after today).
*/
function isFutureDate(dateStr: string): boolean {
const inputDate = new Date(dateStr);
const today = new Date();
today.setHours(0, 0, 0, 0);
inputDate.setHours(0, 0, 0, 0);
return inputDate > today;
}
export const POST = withAuth(async (request: NextRequest, user) => {
try {
const body = (await request.json()) as PeriodLogRequest;
// Validate startDate is present
if (!body.startDate) {
return NextResponse.json(
{ error: "startDate is required" },
{ status: 400 },
);
}
// Validate date format
if (!isValidDateFormat(body.startDate)) {
return NextResponse.json(
{ error: "Invalid date format. Use YYYY-MM-DD" },
{ status: 400 },
);
}
// Validate date is not in the future
if (isFutureDate(body.startDate)) {
return NextResponse.json(
{ error: "startDate cannot be in the future" },
{ status: 400 },
);
}
const pb = createPocketBaseClient();
// Update user's lastPeriodDate
await pb.collection("users").update(user.id, {
lastPeriodDate: body.startDate,
});
// Create PeriodLog record
await pb.collection("period_logs").create({
user: user.id,
startDate: body.startDate,
});
// Calculate updated cycle information
const lastPeriodDate = new Date(body.startDate);
const cycleDay = getCycleDay(lastPeriodDate, user.cycleLength, new Date());
const phase = getPhase(cycleDay);
return NextResponse.json({
message: "Period start date logged successfully",
lastPeriodDate: body.startDate,
cycleDay,
phase,
});
} catch (error) {
console.error("Period logging error:", error);
return NextResponse.json(
{ error: "Failed to update period date" },
{ status: 500 },
);
}
});