From 532d49f570fea4d3e39dc4ec720525aa00fd453b Mon Sep 17 00:00:00 2001 From: Petru Paler Date: Sat, 10 Jan 2026 20:02:07 +0000 Subject: [PATCH] Implement calendar ICS feed and token regeneration (P2.6, P2.7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- IMPLEMENTATION_PLAN.md | 39 ++- .../[userId]/[token].ics/route.test.ts | 231 ++++++++++++++++++ .../calendar/[userId]/[token].ics/route.ts | 66 ++++- .../calendar/regenerate-token/route.test.ts | 177 ++++++++++++++ .../api/calendar/regenerate-token/route.ts | 37 ++- 5 files changed, 527 insertions(+), 23 deletions(-) create mode 100644 src/app/api/calendar/[userId]/[token].ics/route.test.ts create mode 100644 src/app/api/calendar/regenerate-token/route.test.ts diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index ac65380..85d5761 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -35,8 +35,8 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta | POST /api/garmin/tokens | **COMPLETE** | Stores encrypted Garmin OAuth tokens (15 tests) | | DELETE /api/garmin/tokens | **COMPLETE** | Clears tokens and disconnects Garmin (15 tests) | | GET /api/garmin/status | **COMPLETE** | Returns connection status, expiry, warning level (11 tests) | -| GET /api/calendar/[userId]/[token].ics | 501 | Has param extraction, core logic TODO | -| POST /api/calendar/regenerate-token | 501 | Returns Not Implemented | +| GET /api/calendar/[userId]/[token].ics | **COMPLETE** | Token validation, ICS generation, caching headers (10 tests) | +| POST /api/calendar/regenerate-token | **COMPLETE** | Generates 32-char token, returns URL (9 tests) | | POST /api/cron/garmin-sync | **COMPLETE** | Syncs Garmin data for all users, creates DailyLogs (22 tests) | | POST /api/cron/notifications | **COMPLETE** | Sends daily emails with timezone matching, DailyLog handling (20 tests) | @@ -85,6 +85,9 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta | `src/app/api/garmin/tokens/route.test.ts` | **EXISTS** - 15 tests (POST/DELETE tokens, encryption, validation, auth) | | `src/app/api/garmin/status/route.test.ts` | **EXISTS** - 11 tests (connection status, expiry, warning levels) | | `src/app/api/cron/garmin-sync/route.test.ts` | **EXISTS** - 22 tests (auth, user iteration, token handling, Garmin data fetching, DailyLog creation, error handling) | +| `src/app/api/cron/notifications/route.test.ts` | **EXISTS** - 20 tests (timezone matching, DailyLog handling, email sending) | +| `src/app/api/calendar/[userId]/[token].ics/route.test.ts` | **EXISTS** - 10 tests (token validation, ICS generation, caching, error handling) | +| `src/app/api/calendar/regenerate-token/route.test.ts` | **EXISTS** - 9 tests (token generation, URL formatting, auth) | | E2E tests | **NONE** | ### Critical Business Rules (from Spec) @@ -324,21 +327,33 @@ Full feature set for production use. - **Why:** Email notifications are a key feature per spec - **Depends On:** P2.4 -### P2.6: GET /api/calendar/[userId]/[token].ics Implementation -- [ ] Return ICS feed for calendar subscription +### P2.6: GET /api/calendar/[userId]/[token].ics Implementation ✅ COMPLETE +- [x] Return ICS feed for calendar subscription - **Files:** - - `src/app/api/calendar/[userId]/[token].ics/route.ts` - Validate token, generate ICS + - `src/app/api/calendar/[userId]/[token].ics/route.ts` - Validates token, generates ICS with 90 days of phase events - **Tests:** - - Integration test: valid token returns ICS, invalid returns 401 + - `src/app/api/calendar/[userId]/[token].ics/route.test.ts` - 10 tests covering token validation, ICS generation, caching headers, error handling +- **Features Implemented:** + - Token-based authentication (no session required) + - Validates calendar token against user record + - Generates 90 days of phase events using `generateIcsFeed()` + - Returns proper Content-Type header (`text/calendar; charset=utf-8`) + - Caching headers for calendar client optimization + - 404 for non-existent users, 401 for invalid tokens - **Why:** Calendar integration for external apps -- **Note:** Route has param extraction, needs ICS generation (90 days of events per spec) -### P2.7: POST /api/calendar/regenerate-token Implementation -- [ ] Generate new calendar token +### P2.7: POST /api/calendar/regenerate-token Implementation ✅ COMPLETE +- [x] Generate new calendar token - **Files:** - - `src/app/api/calendar/regenerate-token/route.ts` - Create random token, update user + - `src/app/api/calendar/regenerate-token/route.ts` - Creates random 32-char token, updates user - **Tests:** - - `src/app/api/calendar/regenerate-token/route.test.ts` - Test token uniqueness, old URL invalidation + - `src/app/api/calendar/regenerate-token/route.test.ts` - 9 tests covering token generation, URL formatting, auth +- **Features Implemented:** + - 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 - **Why:** Security feature for calendar URLs - **Depends On:** P0.1, P0.2 @@ -584,6 +599,8 @@ P2.14 Mini calendar - [x] **GET /api/garmin/status** - Returns connection status, expiry, warning level, 11 tests (P2.3) - [x] **POST /api/cron/garmin-sync** - Daily sync of Garmin data for all connected users, creates DailyLogs, 22 tests (P2.4) - [x] **POST /api/cron/notifications** - Sends daily email notifications with timezone matching, DailyLog handling, nutrition guidance, 20 tests (P2.5) +- [x] **GET /api/calendar/[userId]/[token].ics** - Returns ICS feed with 90-day phase events, token validation, caching headers, 10 tests (P2.6) +- [x] **POST /api/calendar/regenerate-token** - Generates new 32-char calendar token, returns URL, 9 tests (P2.7) ### Pages - [x] **Login Page** - Email/password form with PocketBase auth, error handling, loading states, redirect, 14 tests (P1.6) diff --git a/src/app/api/calendar/[userId]/[token].ics/route.test.ts b/src/app/api/calendar/[userId]/[token].ics/route.test.ts new file mode 100644 index 0000000..cb90860 --- /dev/null +++ b/src/app/api/calendar/[userId]/[token].ics/route.test.ts @@ -0,0 +1,231 @@ +// ABOUTME: Unit tests for ICS calendar feed API route. +// ABOUTME: Tests GET /api/calendar/[userId]/[token].ics for token validation and ICS generation. + +import type { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { User } from "@/types"; + +// Module-level variable to control mock user lookup +let mockUsers: Map = new Map(); + +// Mock PocketBase +vi.mock("@/lib/pocketbase", () => ({ + createPocketBaseClient: vi.fn(() => ({ + collection: vi.fn(() => ({ + getOne: vi.fn((userId: string) => { + const user = mockUsers.get(userId); + if (!user) { + const error = new Error("Not found"); + (error as unknown as { status: number }).status = 404; + throw error; + } + return { + id: user.id, + email: user.email, + calendarToken: user.calendarToken, + lastPeriodDate: user.lastPeriodDate.toISOString(), + cycleLength: user.cycleLength, + garminConnected: user.garminConnected, + }; + }), + })), + })), +})); + +// Mock ICS generation +const mockGenerateIcsFeed = vi.fn().mockReturnValue(`BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//PhaseFlow//EN +BEGIN:VEVENT +SUMMARY:🔵 MENSTRUAL +DTSTART:20250101 +DTEND:20250105 +END:VEVENT +END:VCALENDAR`); + +vi.mock("@/lib/ics", () => ({ + generateIcsFeed: (options: { lastPeriodDate: Date; cycleLength: number }) => + mockGenerateIcsFeed(options), +})); + +import { GET } from "./route"; + +describe("GET /api/calendar/[userId]/[token].ics", () => { + 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: "valid-calendar-token-abc123def", + lastPeriodDate: new Date("2025-01-15"), + cycleLength: 28, + notificationTime: "07:30", + timezone: "America/New_York", + activeOverrides: [], + created: new Date("2024-01-01"), + updated: new Date("2025-01-10"), + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockUsers = new Map(); + mockUsers.set("user123", mockUser); + }); + + // Helper to create route context with params + function createRouteContext(userId: string, token: string) { + return { + params: Promise.resolve({ userId, token }), + }; + } + + it("returns 401 for invalid token", async () => { + const mockRequest = {} as NextRequest; + const context = createRouteContext("user123", "wrong-token"); + + const response = await GET(mockRequest, context); + + expect(response.status).toBe(401); + const body = await response.json(); + expect(body.error).toContain("Unauthorized"); + }); + + it("returns 404 for non-existent user", async () => { + const mockRequest = {} as NextRequest; + const context = createRouteContext("nonexistent", "some-token"); + + const response = await GET(mockRequest, context); + + expect(response.status).toBe(404); + const body = await response.json(); + expect(body.error).toContain("not found"); + }); + + it("returns 401 when user has no calendar token set", async () => { + mockUsers.set("user456", { + ...mockUser, + id: "user456", + calendarToken: "", + }); + + const mockRequest = {} as NextRequest; + const context = createRouteContext("user456", "any-token"); + + const response = await GET(mockRequest, context); + + expect(response.status).toBe(401); + }); + + it("returns ICS content for valid token", async () => { + const mockRequest = {} as NextRequest; + const context = createRouteContext( + "user123", + "valid-calendar-token-abc123def", + ); + + const response = await GET(mockRequest, context); + + expect(response.status).toBe(200); + const text = await response.text(); + expect(text).toContain("BEGIN:VCALENDAR"); + expect(text).toContain("END:VCALENDAR"); + }); + + it("returns correct Content-Type header", async () => { + const mockRequest = {} as NextRequest; + const context = createRouteContext( + "user123", + "valid-calendar-token-abc123def", + ); + + const response = await GET(mockRequest, context); + + expect(response.headers.get("Content-Type")).toBe( + "text/calendar; charset=utf-8", + ); + }); + + it("calls generateIcsFeed with correct parameters", async () => { + const mockRequest = {} as NextRequest; + const context = createRouteContext( + "user123", + "valid-calendar-token-abc123def", + ); + + await GET(mockRequest, context); + + expect(mockGenerateIcsFeed).toHaveBeenCalledWith( + expect.objectContaining({ + lastPeriodDate: expect.any(Date), + cycleLength: 28, + }), + ); + }); + + it("generates 90 days of events (monthsAhead = 3)", async () => { + const mockRequest = {} as NextRequest; + const context = createRouteContext( + "user123", + "valid-calendar-token-abc123def", + ); + + await GET(mockRequest, context); + + expect(mockGenerateIcsFeed).toHaveBeenCalledWith( + expect.objectContaining({ + monthsAhead: 3, + }), + ); + }); + + it("handles different cycle lengths", async () => { + mockUsers.set("user789", { + ...mockUser, + id: "user789", + calendarToken: "token789", + cycleLength: 35, + }); + + const mockRequest = {} as NextRequest; + const context = createRouteContext("user789", "token789"); + + await GET(mockRequest, context); + + expect(mockGenerateIcsFeed).toHaveBeenCalledWith( + expect.objectContaining({ + cycleLength: 35, + }), + ); + }); + + it("includes Cache-Control header for caching", async () => { + const mockRequest = {} as NextRequest; + const context = createRouteContext( + "user123", + "valid-calendar-token-abc123def", + ); + + const response = await GET(mockRequest, context); + + // Calendar feeds should be cacheable but refresh periodically + const cacheControl = response.headers.get("Cache-Control"); + expect(cacheControl).toBeDefined(); + expect(cacheControl).toContain("max-age"); + }); + + it("is case-sensitive for token matching", async () => { + const mockRequest = {} as NextRequest; + // Token with different case should fail + const context = createRouteContext( + "user123", + "VALID-CALENDAR-TOKEN-ABC123DEF", + ); + + const response = await GET(mockRequest, context); + + expect(response.status).toBe(401); + }); +}); diff --git a/src/app/api/calendar/[userId]/[token].ics/route.ts b/src/app/api/calendar/[userId]/[token].ics/route.ts index 4381a33..4437959 100644 --- a/src/app/api/calendar/[userId]/[token].ics/route.ts +++ b/src/app/api/calendar/[userId]/[token].ics/route.ts @@ -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 }, + ); + } } diff --git a/src/app/api/calendar/regenerate-token/route.test.ts b/src/app/api/calendar/regenerate-token/route.test.ts new file mode 100644 index 0000000..6a69c2e --- /dev/null +++ b/src/app/api/calendar/regenerate-token/route.test.ts @@ -0,0 +1,177 @@ +// ABOUTME: Unit tests for calendar token regeneration API route. +// ABOUTME: Tests POST /api/calendar/regenerate-token for creating new calendar tokens. +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; + +// 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 +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/calendar/regenerate-token", () => { + 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: "old-calendar-token-abc123", + lastPeriodDate: new Date("2025-01-15"), + cycleLength: 28, + notificationTime: "07:30", + timezone: "America/New_York", + activeOverrides: [], + created: new Date("2024-01-01"), + updated: new Date("2025-01-10"), + }; + + beforeEach(() => { + vi.clearAllMocks(); + currentMockUser = null; + }); + + it("returns 401 when not authenticated", async () => { + currentMockUser = null; + + const mockRequest = {} as NextRequest; + const response = await POST(mockRequest); + + expect(response.status).toBe(401); + const body = await response.json(); + expect(body.error).toBe("Unauthorized"); + }); + + it("generates a new random token", async () => { + currentMockUser = mockUser; + + const mockRequest = {} as NextRequest; + const response = await POST(mockRequest); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.token).toBeDefined(); + expect(typeof body.token).toBe("string"); + expect(body.token.length).toBe(32); + }); + + it("generates different tokens on multiple calls", async () => { + currentMockUser = mockUser; + + const mockRequest = {} as NextRequest; + const response1 = await POST(mockRequest); + const body1 = await response1.json(); + + const response2 = await POST(mockRequest); + const body2 = await response2.json(); + + expect(body1.token).not.toBe(body2.token); + }); + + it("updates user record with new token", async () => { + currentMockUser = mockUser; + + const mockRequest = {} as NextRequest; + const response = await POST(mockRequest); + + expect(response.status).toBe(200); + expect(mockPbUpdate).toHaveBeenCalledWith( + "user123", + expect.objectContaining({ + calendarToken: expect.any(String), + }), + ); + + // Verify the token in the update matches the response + const body = await response.json(); + expect(mockPbUpdate).toHaveBeenCalledWith("user123", { + calendarToken: body.token, + }); + }); + + it("returns the calendar URL in response", async () => { + currentMockUser = mockUser; + + const mockRequest = {} as NextRequest; + const response = await POST(mockRequest); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.url).toBeDefined(); + expect(body.url).toContain("/api/calendar/user123/"); + expect(body.url).toContain(".ics"); + }); + + it("URL contains the new token", async () => { + currentMockUser = mockUser; + + const mockRequest = {} as NextRequest; + const response = await POST(mockRequest); + + const body = await response.json(); + expect(body.url).toContain(body.token); + }); + + it("generates URL-safe tokens (alphanumeric only)", async () => { + currentMockUser = mockUser; + + const mockRequest = {} as NextRequest; + const response = await POST(mockRequest); + + const body = await response.json(); + // Token should be alphanumeric (URL-safe) + expect(body.token).toMatch(/^[a-zA-Z0-9]+$/); + }); + + it("works when user has no existing calendar token", async () => { + currentMockUser = { + ...mockUser, + calendarToken: "", + }; + + const mockRequest = {} as NextRequest; + const response = await POST(mockRequest); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.token).toBeDefined(); + expect(body.token.length).toBe(32); + }); + + it("returns success true in response", async () => { + currentMockUser = mockUser; + + const mockRequest = {} as NextRequest; + const response = await POST(mockRequest); + + const body = await response.json(); + expect(body.success).toBe(true); + }); +}); diff --git a/src/app/api/calendar/regenerate-token/route.ts b/src/app/api/calendar/regenerate-token/route.ts index ad87f87..6ce4769 100644 --- a/src/app/api/calendar/regenerate-token/route.ts +++ b/src/app/api/calendar/regenerate-token/route.ts @@ -1,8 +1,39 @@ // ABOUTME: API route for regenerating calendar subscription token. // ABOUTME: Creates new random token, invalidating old calendar URLs. +import { randomBytes } from "node:crypto"; + import { NextResponse } from "next/server"; -export async function POST() { - // TODO: Implement token regeneration - return NextResponse.json({ message: "Not implemented" }, { status: 501 }); +import { withAuth } from "@/lib/auth-middleware"; +import { createPocketBaseClient } from "@/lib/pocketbase"; + +/** + * Generates a cryptographically secure random 32-character alphanumeric token. + */ +function generateToken(): string { + // Generate enough random bytes to ensure we get 32 alphanumeric characters + // after base64 encoding and filtering. Generate extra to account for + // characters that might be filtered out. + return randomBytes(32).toString("hex").slice(0, 32); } + +export const POST = withAuth(async (_request, user) => { + // Generate new random token + const newToken = generateToken(); + + // Update user record with new token + const pb = createPocketBaseClient(); + await pb.collection("users").update(user.id, { + calendarToken: newToken, + }); + + // Build the calendar URL + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://phaseflow.app"; + const url = `${baseUrl}/api/calendar/${user.id}/${newToken}.ics`; + + return NextResponse.json({ + success: true, + token: newToken, + url, + }); +});