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>
89 lines
2.8 KiB
TypeScript
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 },
|
|
);
|
|
}
|
|
}
|