Implement calendar ICS feed and token regeneration (P2.6, P2.7)

Add two calendar-related API endpoints:

P2.6 - GET /api/calendar/[userId]/[token].ics:
- Token-based authentication (no session required)
- Validates calendar token against user record
- Generates 90 days of phase events using generateIcsFeed()
- Returns proper Content-Type and Cache-Control headers
- 404 for non-existent users, 401 for invalid tokens
- 10 tests covering all scenarios

P2.7 - POST /api/calendar/regenerate-token:
- Requires authentication via withAuth() middleware
- Generates cryptographically secure 32-character hex token
- Updates user's calendarToken field in database
- Returns new token and formatted calendar URL
- Old tokens immediately invalidated
- 9 tests covering token generation and auth

Total: 19 new tests, 360 tests passing

🤖 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 20:02:07 +00:00
parent 901543cb4d
commit 532d49f570
5 changed files with 527 additions and 23 deletions

View File

@@ -2,6 +2,9 @@
// ABOUTME: Returns subscribable iCal feed with cycle phases and warnings.
import { type NextRequest, NextResponse } from "next/server";
import { generateIcsFeed } from "@/lib/ics";
import { createPocketBaseClient } from "@/lib/pocketbase";
interface RouteParams {
params: Promise<{
userId: string;
@@ -11,13 +14,58 @@ interface RouteParams {
export async function GET(_request: NextRequest, { params }: RouteParams) {
const { userId, token } = await params;
void token; // Token will be used for validation
// TODO: Implement ICS feed generation
// Validate token, generate ICS content, return with correct headers
return new NextResponse(`ICS feed for user ${userId} not implemented`, {
status: 501,
headers: {
"Content-Type": "text/calendar; charset=utf-8",
},
});
try {
// Fetch user from database
const pb = createPocketBaseClient();
const user = await pb.collection("users").getOne(userId);
// Check if user has a calendar token set
if (!user.calendarToken) {
return NextResponse.json(
{ error: "Unauthorized: Calendar not configured" },
{ status: 401 },
);
}
// Validate token (case-sensitive comparison)
if (user.calendarToken !== token) {
return NextResponse.json(
{ error: "Unauthorized: Invalid token" },
{ status: 401 },
);
}
// Generate ICS feed with 90 days of events (3 months)
const icsContent = generateIcsFeed({
lastPeriodDate: new Date(user.lastPeriodDate as string),
cycleLength: user.cycleLength as number,
monthsAhead: 3,
});
// Return ICS content with appropriate headers
return new NextResponse(icsContent, {
status: 200,
headers: {
"Content-Type": "text/calendar; charset=utf-8",
"Cache-Control": "public, max-age=3600", // Cache for 1 hour
"Content-Disposition": "attachment; filename=phaseflow-calendar.ics",
},
});
} catch (error) {
// Check if error is a "not found" error from PocketBase
if (
error instanceof Error &&
(error as unknown as { status?: number }).status === 404
) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
// Re-throw unexpected errors
console.error("Calendar feed error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}