Files
phaseflow/src/app/api/calendar/[userId]/[token].ics/route.ts
Petru Paler ff3d8fad2c Add Playwright fixtures with 5 test user types for e2e tests
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>
2026-01-15 05:54:49 +00:00

89 lines
2.8 KiB
TypeScript

// ABOUTME: API route for ICS calendar feed generation.
// ABOUTME: Returns subscribable iCal feed with cycle phases and warnings.
import { type NextRequest, NextResponse } from "next/server";
import { generateIcsFeed } from "@/lib/ics";
import { logger } from "@/lib/logger";
import { createPocketBaseClient } from "@/lib/pocketbase";
interface RouteParams {
params: Promise<{
userId: string;
token: string;
}>;
}
export async function GET(_request: NextRequest, { params }: RouteParams) {
const { userId, token: rawToken } = await params;
// Strip .ics suffix if present (Next.js may include it in the param)
const token = rawToken.endsWith(".ics") ? rawToken.slice(0, -4) : rawToken;
const pb = createPocketBaseClient();
try {
// Fetch user from database
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 },
);
}
// Fetch period logs for prediction accuracy display
const periodLogs = await pb.collection("period_logs").getFullList({
filter: `user = "${userId}"`,
sort: "-startDate",
});
// 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,
periodLogs: periodLogs.map((log) => ({
id: log.id,
user: log.user as string,
startDate: new Date(log.startDate as string),
predictedDate: log.predictedDate
? new Date(log.predictedDate as string)
: null,
created: new Date(log.created as string),
})),
});
// 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 });
}
logger.error({ err: error, userId }, "Calendar feed error");
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}