Repository navigation
Add Google Calendar integration with OAuth and sync - #369
Conversation
Comprehensive planning document for optional Google Calendar integration: - Reuses existing Google OAuth (just adds calendar scope) - Settings page integration with connect/disconnect flow - Subtle calendar empty state suggestion for unconnected users - Phased approach: import-only → export → two-way sync - Database schema for token storage and sync state tracking https://claude.ai/code/session_01WcRaPPV1DmhjznbYLxLXP9
- Add googleCalendarConnections table for OAuth tokens and sync config - Extend calendarEvents with Google sync tracking fields: - googleEventId, googleCalendarId for sync matching - syncedFromGoogle flag for imported events - lastGoogleSync timestamp - googleSyncReadOnly to prevent editing synced events - Generate migration 0066_soft_tana_nile.sql https://claude.ai/code/session_01WcRaPPV1DmhjznbYLxLXP9
OAuth endpoints: - /api/integrations/google-calendar/connect - initiate calendar.readonly OAuth - /api/integrations/google-calendar/callback - handle callback, encrypt & store tokens Token management (zero-trust): - Encrypted storage using AES-256-GCM - Automatic token refresh before expiration - Connection status tracking (active/expired/error) API client (pure functions): - listCalendars, listEvents, getEvent - Pagination handling - Error classification (auth errors, rate limits) Event transformation: - Google → PageSpace event mapping - Recurrence rule parsing (RRULE → JSON) - HTML description sanitization - Color and visibility mapping https://claude.ai/code/session_01WcRaPPV1DmhjznbYLxLXP9
Settings page: - Add Google Calendar entry to settings navigation - Create settings page with connect/disconnect/sync UI - Show connection status, last sync time, and errors - Privacy information card API endpoints: - /status - Get connection status for settings page - /disconnect - Revoke OAuth, clear tokens, update events - /sync - Trigger manual sync Sync service: - Full sync for initial connection - Incremental sync using Google's sync tokens - Upsert logic for events (create/update/delete) - Handle cancelled events as soft deletes https://claude.ai/code/session_01WcRaPPV1DmhjznbYLxLXP9
Calendar views: - Add subtle "Import from Google Calendar" link in empty states - Show in AgendaView and MobileCalendarView AI calendar tools: - Update canEditEvent to check for Google sync read-only flag - Return descriptive error messages for synced events - Prevent editing/deleting read-only synced events https://claude.ai/code/session_01WcRaPPV1DmhjznbYLxLXP9
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
📝 WalkthroughWalkthroughAdds a complete Google Calendar integration: OAuth connect/callback routes, token refresh and status management, per-user incremental/full sync service, DB schema and migrations for connections/events, API endpoints (connect/status/sync/disconnect), UI settings and calendar import hints, and pure utilities for API and event transformation. Changes
Sequence Diagram(s)sequenceDiagram
actor User
participant Client as Client/Browser
participant PageSpace as PageSpace API
participant Google as Google OAuth
participant DB as Database
User->>Client: Click "Connect Google Calendar"
Client->>PageSpace: POST /api/integrations/google-calendar/connect
PageSpace->>PageSpace: Auth check, rate-limit, build signed state
PageSpace->>Client: Return OAuth URL
Client->>Google: Redirect to Google OAuth
Google->>Client: Redirect callback with code & state
Client->>PageSpace: GET /api/integrations/google-calendar/callback?code=&state=
PageSpace->>PageSpace: Verify HMAC state and expiry
PageSpace->>Google: Exchange code for tokens
Google->>PageSpace: Return tokens
PageSpace->>Google: Fetch userinfo
Google->>PageSpace: Return user details
PageSpace->>PageSpace: Encrypt tokens, upsert connection
PageSpace->>DB: Persist google_calendar_connections
PageSpace->>Client: Redirect to returnUrl with connected flag
sequenceDiagram
actor User
participant Client as Client/Browser
participant PageSpace as PageSpace API
participant GoogleAPI as Google Calendar API
participant DB as Database
User->>Client: Click "Sync Now"
Client->>PageSpace: POST /api/integrations/google-calendar/sync
PageSpace->>DB: Load connection & sync cursors
PageSpace->>PageSpace: getValidAccessToken(userId)
alt refresh required
PageSpace->>GoogleAPI: Token refresh request
GoogleAPI->>PageSpace: New access token
PageSpace->>DB: Update encrypted token & expiry
end
loop per selected calendar
PageSpace->>GoogleAPI: listEvents(calendarId, syncToken?)
alt syncToken expired (410)
GoogleAPI->>PageSpace: 410 -> full sync fallback
else
GoogleAPI->>PageSpace: Return events & nextSyncToken
end
PageSpace->>PageSpace: Transform events to PageSpace format
loop each event
PageSpace->>DB: Create/Update/Delete calendar_events
end
PageSpace->>DB: Persist updated syncCursor & lastSyncAt
end
PageSpace->>Client: Return sync result counts
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Fix all issues with AI agents
In `@apps/web/src/app/api/integrations/google-calendar/callback/route.ts`:
- Around line 181-184: The log currently includes raw Google email via
loggers.auth.info('Google Calendar connected successfully', { userId,
googleEmail })—replace raw logging with a non-PII representation: compute a
maskedEmail (e.g., keep first char of local-part and domain with stars for the
rest) or an irreversible hash (e.g., sha256) and log that instead (e.g.,
maskedEmail or emailHash) along with userId; update the logging call in the
Google Calendar callback route (where loggers.auth.info is invoked) and ensure
the original googleEmail is not written to logs or persisted.
In `@apps/web/src/app/api/integrations/google-calendar/connect/route.ts`:
- Around line 46-51: The handler currently calls req.json() directly which will
throw on empty or invalid JSON and lead to a 500; wrap the JSON parsing in a
try/catch (or pre-check Content-Type) before calling connectSchema.safeParse,
detect parse errors or an empty body, and return a 400 Response.json with a
clear error (e.g., "Invalid or empty JSON body") instead of allowing the error
to propagate; update the route's error handling so only unexpected errors still
return 500 while JSON parse/validation failures use 400 (references: req.json(),
connectSchema.safeParse, the route handler logic).
In `@apps/web/src/app/settings/integrations/google-calendar/page.tsx`:
- Around line 60-68: The effect references handleSync but doesn't include it in
the dependency array, causing potential stale closures; update the code by
memoizing handleSync with useCallback (including csrfToken and any other used
state/props in its dependency list) or move the sync logic into a stable
function, then add handleSync to the useEffect dependency array so the effect
uses the current closure; refer to handleSync and the useEffect block around
justConnected/router to locate where to apply useCallback or extraction.
In `@apps/web/src/lib/integrations/google-calendar/sync-service.ts`:
- Around line 78-106: The current loop over calendarsToSync writes each
calendarResult.syncCursor into the single connection.syncCursor field, causing
later cursors to overwrite earlier ones; update the logic to store per-calendar
cursors (e.g., a connection.syncCursors map keyed by calendarId) so each
calendar's token is preserved: read connection.syncCursors (or initialize {}),
use the existing per-calendar token when calling syncCalendar, and after a
successful sync set cursors[calendarId] = calendarResult.syncCursor and persist
that JSON map via the update to googleCalendarConnections (also update
lastSyncAt/lastSyncError/updatedAt as before); ensure code paths that read a
sync token pass cursors[calendarId] into syncCalendar instead of
connection.syncCursor.
🧹 Nitpick comments (15)
apps/web/src/app/api/integrations/google-calendar/callback/route.ts (1)
60-67: Use constant-time compare for the OAuth state HMAC.This avoids timing side‑channels when validating the signature. Please verify the Node runtime supports
crypto.timingSafeEqual.🔐 Suggested hardening
const expectedSignature = crypto .createHmac('sha256', process.env.OAUTH_STATE_SECRET!) .update(JSON.stringify(stateWithSignature.data)) .digest('hex'); - if (stateWithSignature.sig !== expectedSignature) { + const providedSignature = stateWithSignature.sig; + const expectedBuffer = Buffer.from(expectedSignature, 'hex'); + const providedBuffer = Buffer.from(providedSignature, 'hex'); + + if ( + expectedBuffer.length !== providedBuffer.length || + !crypto.timingSafeEqual(expectedBuffer, providedBuffer) + ) { loggers.auth.warn('Google Calendar OAuth state signature mismatch'); return NextResponse.redirect( new URL('/settings/integrations/google-calendar?error=invalid_state', baseUrl) ); }apps/web/src/app/api/integrations/google-calendar/disconnect/route.ts (2)
32-46: Token revocation response is not checked.The token revocation request is made but its response status is not validated. While this is best-effort, checking the response could help distinguish between "already revoked" (which is fine) and actual network/server errors for better logging.
🔧 Proposed improvement for more informative logging
try { const accessToken = await decrypt(connection.accessToken); - await fetch(`https://oauth2.googleapis.com/revoke?token=${accessToken}`, { + const revokeResponse = await fetch(`https://oauth2.googleapis.com/revoke?token=${accessToken}`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, }); - loggers.auth.info('Google Calendar token revoked', { userId }); + if (revokeResponse.ok) { + loggers.auth.info('Google Calendar token revoked', { userId }); + } else { + loggers.auth.warn('Google token revocation returned non-OK status', { + userId, + status: revokeResponse.status, + }); + } } catch (error) {
48-60: Consider also clearingtokenExpiresAt.When clearing sensitive token data, the
tokenExpiresAtfield retains timing information about the revoked tokens. For completeness, consider setting it to a past date or null to indicate the tokens are no longer valid.🔧 Proposed fix
.set({ status: 'disconnected', statusMessage: 'Disconnected by user', // Clear tokens for security (they're revoked anyway) accessToken: 'REVOKED', refreshToken: 'REVOKED', + tokenExpiresAt: new Date(0), // Mark as expired updatedAt: new Date(), })apps/web/src/app/api/integrations/google-calendar/connect/route.ts (1)
55-67: UnusedclientIPvariable in rate limiting.The
clientIPis fetched but the rate limit is keyed only byuserId. If the intent is user-level rate limiting (which is reasonable), theclientIPfetch on line 56 is only needed for logging. This is fine, but worth noting for clarity.tasks/google-calendar-sync.md (1)
24-30: Add language specifiers to fenced code blocks.The ASCII wireframes/diagrams at lines 24, 34, and 64 lack language specifiers. While these are visual diagrams rather than code, adding a language hint like
textorplaintextsatisfies markdown linting rules and improves accessibility for screen readers.📝 Example fix for line 24
-``` +```text Google Calendar Sync ├── Icon: Calendar + Google colorsAlso applies to: 34-58, 64-74
apps/web/src/app/settings/integrations/google-calendar/page.tsx (1)
163-193: Consider adding a confirmation dialog for disconnect.Per the epic requirements (tasks/google-calendar-sync.md line 277-278), users should see a confirmation dialog explaining data retention before disconnecting. This helps prevent accidental disconnects.
apps/web/src/lib/integrations/google-calendar/sync-service.ts (1)
179-196: Recursive fallback for 410 could theoretically cause deep recursion.If the Google API repeatedly returns 410 (e.g., due to a bug or misconfiguration), the recursive call to
syncCalendarwithundefinedsyncToken could fail again and throw, but a truly persistent 410 on non-token requests would indicate a deeper issue. Consider adding a guard to prevent unintended recursion.🛡️ Proposed guard against repeated 410s
const syncCalendar = async ( userId: string, accessToken: string, calendarId: string, targetDriveId: string | null, markAsReadOnly: boolean, syncToken: string | undefined | null, timeMin: Date, - timeMax: Date + timeMax: Date, + isRetry = false ): Promise<...> => { // ... if (!listResult.success) { // If sync token is invalid, fall back to full sync - if (listResult.statusCode === 410) { + if (listResult.statusCode === 410 && !isRetry) { loggers.api.info('Sync token expired, performing full sync', { userId, calendarId }); return syncCalendar( // ... params - undefined, // No sync token + undefined, timeMin, - timeMax + timeMax, + true // Mark as retry ); }packages/db/src/schema/calendar.ts (1)
138-180: Well-structured OAuth token storage with appropriate security considerations.The schema correctly:
- Enforces one connection per user via unique constraint on
userId- Uses cascade delete for user cleanup
- Uses set null for drive references (preserving connections if drive is deleted)
- Stores encrypted tokens (as documented in PR objectives)
Consider: The
userIdxindex on line 176 is redundant sinceuserIdalready has a unique constraint (line 145), which implicitly creates an index. You could remove this index to reduce storage overhead.♻️ Optional: Remove redundant index
}, (table) => { return { - userIdx: index('google_calendar_connections_user_id_idx').on(table.userId), statusIdx: index('google_calendar_connections_status_idx').on(table.status), targetDriveIdx: index('google_calendar_connections_target_drive_id_idx').on(table.targetDriveId), } });apps/web/src/lib/integrations/google-calendar/event-transform.ts (2)
38-58: Consider logging when fallback is used inparseGoogleDateTime.The fallback to current time (lines 56-57) silently handles invalid input. While defensive, this could mask data issues from Google's API. Consider whether silent fallback is appropriate or if this should be logged/flagged.
148-154: Unsafe type assertion on BYDAY parsing.The cast on line 150 assumes all split values are valid day codes, but invalid values could slip through. The filter on lines 151-153 helps, but the type assertion happens before filtering.
♻️ Safer BYDAY parsing
// Parse BYDAY (for weekly) if (ruleMap.BYDAY) { - const days = ruleMap.BYDAY.split(',') as ('MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA' | 'SU')[]; - rule.byDay = days.filter((d) => - ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'].includes(d) - ); + const validDays = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'] as const; + rule.byDay = ruleMap.BYDAY.split(',') + .filter((d): d is typeof validDays[number] => validDays.includes(d as typeof validDays[number])); }apps/web/src/lib/integrations/google-calendar/token-refresh.ts (3)
48-85: Token refresh implementation looks correct.Uses the official
google-auth-libraryOAuth2Client properly. The 1-hour default expiry fallback (line 75) is reasonable when Google doesn't returnexpiry_date.Note: The
error as Errorcast (line 82) may lose information if the error isn't an Error instance. Consider using a safer pattern.♻️ Safer error handling
} catch (error) { - loggers.auth.error('Token refresh failed', error as Error); + loggers.auth.error('Token refresh failed', error instanceof Error ? error : new Error(String(error))); return null; }
147-164: RedundantupdatedAtassignment.The schema defines
updatedAtwith$onUpdate(() => new Date())which auto-updates on modifications. Setting it manually in line 158 is redundant.♻️ Remove redundant updatedAt
await db .update(googleCalendarConnections) .set({ accessToken: encryptedAccessToken, tokenExpiresAt: refreshResult.expiresAt, status: 'active', statusMessage: null, - updatedAt: new Date(), }) .where(eq(googleCalendarConnections.userId, userId));
174-187: Same redundantupdatedAtinupdateConnectionStatus.Same issue as above - the schema handles
updatedAtautomatically.♻️ Remove redundant updatedAt
export const updateConnectionStatus = async ( userId: string, status: GoogleCalendarConnection['status'], statusMessage: string | null ): Promise<void> => { await db .update(googleCalendarConnections) .set({ status, statusMessage, - updatedAt: new Date(), }) .where(eq(googleCalendarConnections.userId, userId)); };apps/web/src/lib/integrations/google-calendar/api-client.ts (2)
166-221: Consider adding a timeout to prevent hanging requests.The
fetchcall has no timeout, which could cause the request to hang indefinitely if Google's API is unresponsive. Consider usingAbortControllerwith a timeout.♻️ Add request timeout
+const REQUEST_TIMEOUT_MS = 30000; // 30 seconds + const makeGoogleApiRequest = async <T>( url: string, accessToken: string ): Promise<GoogleApiResult<T>> => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { const response = await fetch(url, { headers: buildAuthHeader(accessToken), + signal: controller.signal, }); + clearTimeout(timeoutId); // ... rest of the function } catch (error) { + clearTimeout(timeoutId); + if (error instanceof Error && error.name === 'AbortError') { + loggers.api.error('Google Calendar API request timed out', error); + return { success: false, error: 'Request timed out' }; + } loggers.api.error('Google Calendar API request failed', error as Error); return { success: false, error: error instanceof Error ? error.message : 'Unknown error', }; } };
253-300: Consider adding a pagination safety limit.The
do-whileloop on lines 278-297 continues whilepageTokenexists. If the API misbehaves or has an unexpectedly large dataset, this could result in excessive requests. Consider adding a max pages limit as a safeguard.♻️ Add pagination limit
+const MAX_PAGES = 100; // Safety limit + export const listEvents = async ( accessToken: string, calendarId: string, options: { timeMin?: Date; timeMax?: Date; maxResults?: number; syncToken?: string; } = {} ): Promise<GoogleApiResult<{ events: GoogleCalendarEvent[]; nextSyncToken?: string }>> => { const events: GoogleCalendarEvent[] = []; let pageToken: string | undefined; let nextSyncToken: string | undefined; + let pageCount = 0; // ... options setup ... do { + if (++pageCount > MAX_PAGES) { + loggers.api.warn('Pagination limit reached for calendar events', { calendarId, pageCount }); + break; + } const url = buildEventsListUrl(calendarId, { ...listOptions, pageToken }); // ... rest of the loop } while (pageToken);
- Remove PII (googleEmail) from auth logs in callback route - Handle empty/invalid JSON body gracefully in connect route - Fix useEffect missing dependency with useCallback for handleSync - Fix per-calendar sync cursor storage (prevents cursor overwrite) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
|
Addressed all CodeRabbit review feedback in commit c6fc6d3: 1. Remove PII from auth logs (callback/route.ts:181-184)
2. Handle empty/invalid JSON body gracefully (connect/route.ts:46-51)
3. Fix useEffect missing dependency (settings page.tsx:60-68)
4. Fix per-calendar sync cursor storage (sync-service.ts:78-106)
|
- Replace non-existent @/lib/api/fetchWithAuth with @/lib/auth/auth-fetch - Remove non-existent useCSRF hook - fetchWithAuth handles CSRF automatically - Follows established patterns from billing and connections pages Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@apps/web/src/app/settings/integrations/google-calendar/page.tsx`:
- Around line 195-244: getStatusBadge currently falls through to the default
"Disconnected" for unknown statuses but the integration type includes "pending"
and "revoked", so update getStatusBadge to explicitly handle
status.connection.status === "pending" and "revoked" (in addition to the
existing "active", "expired", and "error") and return clear Badge variants
(e.g., a neutral/info style for "Pending" and a destructive/revoked style for
"Revoked") so the UI accurately reflects those states; locate the switch in
getStatusBadge and add cases for "pending" and "revoked" using the Badge
component and appropriate icons/text.
- Add pending and revoked to ConnectionStatus type - Show appropriate badges for all connection status states Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Summary of Review Feedback AddressedAll CodeRabbit review comments have been addressed across 4 commits: Commit c6fc6d3 - Initial Review Fixes
Commit e478842 - Infrastructure Fix
Commit 47e148f - Status Badge Handling
CI Status
All review threads resolved. PR is ready for final review. |
Summary
Implements complete Google Calendar integration allowing users to import calendar events into PageSpace. Includes OAuth 2.0 authentication flow, event synchronization, and connection management.
Key Changes
API Routes
/api/integrations/google-calendar/connect- Initiates OAuth flow with CSRF protection and rate limiting/api/integrations/google-calendar/callback- Handles OAuth callback, exchanges auth code for tokens, stores encrypted credentials/api/integrations/google-calendar/status- Returns connection status and sync metadata/api/integrations/google-calendar/sync- Triggers manual calendar sync/api/integrations/google-calendar/disconnect- Revokes tokens and disconnects integrationFrontend
/settings/integrations/google-calendar- Settings page for managing Google Calendar connection with sync status, last sync time, and error handling/settingsto include Google Calendar integration linkLibraries
google-calendar/api-client.ts- Pure functions for Google Calendar API interactions (list calendars, list events, get event)google-calendar/sync-service.ts- Handles incremental sync logic with cursor tracking and conflict resolutionImplementation Details
Security
@pagespace/libencryptionOAuth Flow
offlineaccess type to obtain refresh tokenscalendar.readonlyscope for read-only accessinclude_granted_scopesSync Strategy
syncTokenfor efficiencylastSyncAt,lastSyncError, andsyncCursorfor monitoringData Storage
Error Handling
https://claude.ai/code/session_01WcRaPPV1DmhjznbYLxLXP9
Summary by CodeRabbit
New Features
Chores
Documentation