Skip to content

Add Google Calendar sync#38

Merged
alecvdp merged 2 commits intomainfrom
cyrus/irl-3-add-google-calendar-sync
Apr 19, 2026
Merged

Add Google Calendar sync#38
alecvdp merged 2 commits intomainfrom
cyrus/irl-3-add-google-calendar-sync

Conversation

@alecvdp
Copy link
Copy Markdown
Owner

@alecvdp alecvdp commented Apr 19, 2026

Assignee: @alecvdp (alecvdpoel)

Summary

  • Google OAuth 2.0 flow — Connect/disconnect a Google account via /api/auth/google. Tokens (access + refresh) are stored in a new GoogleAccount table and auto-refreshed on expiry.
  • Lazy background sync — On each page load, if the last sync was >5 minutes ago, upcoming Google Calendar events (next 30 days) are fetched and upserted into the Event table. A dedicated /api/sync/calendar endpoint is also available for external cron triggering.
  • UI integration — Google-sourced events appear in the Calendar & Agenda widget with a blue "Google" badge and are read-only (no delete button). A "Connect Google Calendar" link shows when no account is connected; connected accounts show their email with a disconnect option.
  • Schema changesEvent model extended with source, googleEventId (unique), and endDate fields. New GoogleAccount model for OAuth token storage.

New environment variables required

Variable Description
GOOGLE_CLIENT_ID Google Cloud OAuth 2.0 client ID
GOOGLE_CLIENT_SECRET OAuth client secret
GOOGLE_REDIRECT_URI Callback URL (e.g. https://your-domain/api/auth/google/callback)

Resolves IRL-3

Test plan

  • Configure Google Cloud OAuth credentials and set environment variables
  • Click "Connect Google Calendar" → completes OAuth flow → redirects back to dashboard
  • Verify Google Calendar events appear with "Google" badge
  • Verify Google events are read-only (no delete button)
  • Verify manual events still work normally (add/delete)
  • Verify lazy sync respects 5-minute cooldown
  • Hit /api/sync/calendar directly → returns sync count
  • Disconnect account → Google events removed, "Connect" link reappears
  • Verify build succeeds: npm run build
  • Verify tests pass: npm test (31/31 passing)

Tip: I will respond to comments that @ mention @cyrusagent on this PR. You can also submit a review with all your feedback at once, and I will automatically wake up to address each comment.


Open in Devin Review

Implements full Google Calendar integration:
- OAuth 2.0 authorization flow (connect/disconnect Google account)
- Background sync fetches upcoming 30 days of events on page load
  with a 5-minute cooldown to avoid excessive API calls
- Dedicated /api/sync/calendar endpoint for external cron triggering
- Google events appear in the agenda with a distinct "Google" badge
  and are read-only (no delete button)
- New Prisma models: GoogleAccount for token storage, Event model
  extended with source/googleEventId/endDate fields

Requires GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and
GOOGLE_REDIRECT_URI environment variables to be configured.

Resolves IRL-3
Copilot AI review requested due to automatic review settings April 19, 2026 04:09
@linear
Copy link
Copy Markdown

linear Bot commented Apr 19, 2026

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

View 6 additional findings in Devin Review.

Open in Devin Review

Comment on lines +132 to +142
const existingGoogleEvents = await prisma.event.findMany({
where: { source: "google" },
select: { id: true, googleEventId: true },
});

for (const existing of existingGoogleEvents) {
if (existing.googleEventId && !googleEventIds.has(existing.googleEventId)) {
await prisma.event.delete({ where: { id: existing.id } });
totalRemoved++;
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Cross-account Google event deletion during sync loop

In syncCalendarEvents, the cleanup phase at line 132 queries ALL events with source: "google" but compares them against only the current account's googleEventIds. When multiple Google accounts are connected, processing account A will delete events that were synced from account B (because those event IDs aren't in account A's calendar). Then processing account B re-creates its events but deletes account A's. The net result is that only events from the last account processed survive each sync cycle.

Illustration of the bug with two accounts
  1. Loop iteration for Account A: upserts A's events, then queries all source: "google" events and deletes any not in A's googleEventIds → deletes B's events.
  2. Loop iteration for Account B: upserts B's events, then queries all source: "google" events and deletes any not in B's googleEventIds → deletes A's events.

The fix should either scope the cleanup query to the current account's events, or collect all googleEventIds across all accounts before performing deletion.

Prompt for agents
In syncCalendarEvents() in src/lib/google-calendar.ts, the event cleanup logic at lines 132-142 runs inside the per-account loop but queries ALL google-sourced events (where: { source: "google" }). It then deletes any event whose googleEventId is not in the current account's googleEventIds set. This means processing one account deletes events from other accounts.

There are two possible approaches to fix:
1. Accumulate all googleEventIds from ALL accounts first (before the loop), then do a single cleanup pass after the loop using the combined set.
2. Add a foreign key or field linking events to their source GoogleAccount, so the cleanup query can be scoped to the current account (where: { source: "google", googleAccountId: account.id }).

Approach 1 is simpler and doesn't require a schema change. Move the cleanup logic (lines 131-142) outside the for loop and build a combined set of all google event IDs from all accounts.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

// Delete all Google events from this account
// Since we only support one calendar per account right now,
// remove all Google-sourced events when disconnecting
await prisma.event.deleteMany({ where: { source: "google" } });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 disconnectGoogleAccount deletes Google events from ALL accounts, not just the disconnected one

At src/lib/google-calendar.ts:187, disconnectGoogleAccount runs prisma.event.deleteMany({ where: { source: "google" } }) which deletes all Google-sourced events regardless of which account they came from. If two Google accounts are connected and a user disconnects one, all synced events from both accounts are deleted. The comment acknowledges this limitation but the UI (EventsWidget.tsx:142-157) renders per-account disconnect buttons, implying per-account granularity that doesn't exist.

Prompt for agents
In disconnectGoogleAccount (src/lib/google-calendar.ts:183-188), the deleteMany call uses { where: { source: "google" } } which deletes ALL google-sourced events, not just those belonging to the disconnected account. Since there's no foreign key from Event to GoogleAccount, there's no way to scope this correctly with the current schema.

To fix this properly, add a googleAccountId field to the Event model (with a relation to GoogleAccount), populate it during sync (in syncCalendarEvents), and then use it in the disconnect delete query: prisma.event.deleteMany({ where: { source: "google", googleAccountId: id } }). This also fixes the related cross-account deletion bug in syncCalendarEvents.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds Google Calendar read-only sync into the dashboard by introducing a Google OAuth flow, storing tokens in Prisma, lazily syncing upcoming events into the Event table, and exposing UI/endpoint hooks to connect/disconnect and trigger syncs.

Changes:

  • Add Google OAuth endpoints (/api/auth/google + callback) and token storage via new GoogleAccount model.
  • Add Google Calendar sync logic (lazy sync on getEvents() + manual /api/sync/calendar endpoint) and extend Event schema for Google-sourced events.
  • Update Calendar/Agenda UI to show Google connection status and mark Google events as read-only with a “Google” badge.

Reviewed changes

Copilot reviewed 13 out of 14 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
src/proxy.ts Allows Google auth routes through the session gate.
src/lib/google-auth.ts Builds OAuth2 client + authorization URL for Google.
src/app/api/auth/google/route.ts Redirects to Google consent screen.
src/app/api/auth/google/callback/route.ts Exchanges code for tokens and upserts GoogleAccount.
src/lib/google-calendar.ts Implements token refresh, event fetch, upsert/delete sync, and disconnect.
src/app/api/sync/calendar/route.ts Adds a sync trigger endpoint returning counts.
src/app/actions/events.ts Triggers lazy sync before listing events; adds calendar status + disconnect action.
src/app/page.tsx Fetches/passes Google account status into the Events widget.
src/components/EventsWidget.tsx Shows connect/disconnect UI; labels Google events; disables delete for Google events.
src/components/EventsWidget.module.css Styles for connection status and Google badge.
prisma/schema.prisma Adds GoogleAccount; extends Event with source/googleEventId/endDate.
prisma/migrations/.../migration.sql Creates the new table and migrates Event.
package.json / package-lock.json Adds google-auth-library.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +23 to +27
// Get the user's email from the token info
client.setCredentials(tokens);
const tokenInfo = await client.getTokenInfo(tokens.access_token);
const email = tokenInfo.email || "unknown";

Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OAuth callback is attempting to derive the user email via client.getTokenInfo(tokens.access_token) and tokenInfo.email, but the requested scopes are calendar-only (no openid/email/userinfo.email). In that case token info typically won’t contain an email, causing email to become "unknown" and breaking the GoogleAccount.email @unique upsert (and potentially collapsing multiple users into one row). Request an email-capable scope and/or use an ID token / userinfo endpoint to reliably identify the account; avoid defaulting to a constant like "unknown".

Copilot uses AI. Check for mistakes.
Comment on lines +131 to +135
// Remove Google events that are no longer in the API response
const existingGoogleEvents = await prisma.event.findMany({
where: { source: "google" },
select: { id: true, googleEventId: true },
});
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

existingGoogleEvents is fetched with where: { source: "google" } (no account scoping). When multiple GoogleAccount rows exist, syncing one account can delete events from other accounts. To support multiple accounts, associate events to an account (e.g. googleAccountId) and scope the query/deletes accordingly; otherwise explicitly enforce a single connected account.

Copilot uses AI. Check for mistakes.
Comment on lines +184 to +186
// Delete all Google events from this account
// Since we only support one calendar per account right now,
// remove all Google-sourced events when disconnecting
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

disconnectGoogleAccount() deletes all events with source: "google", not just events for the disconnected account. This will remove other accounts’ events if multiple accounts are connected. Either scope deletions to the account (requires storing an account reference on Event) or enforce a single Google account connection in the data model/UI.

Suggested change
// Delete all Google events from this account
// Since we only support one calendar per account right now,
// remove all Google-sourced events when disconnecting
const accounts = await prisma.googleAccount.findMany({
select: { id: true },
});
const accountExists = accounts.some((account) => account.id === id);
if (!accountExists) {
throw new Error("Google account not found");
}
// This implementation only safely supports deleting all Google events
// when there is a single connected Google account. Without storing an
// account reference on Event, deleting by source would remove events
// belonging to other connected accounts.
if (accounts.length > 1) {
throw new Error(
"Disconnecting one of multiple Google accounts is not supported without account-scoped event ownership"
);
}

Copilot uses AI. Check for mistakes.
Comment thread src/lib/google-auth.ts
@@ -0,0 +1,26 @@
import { OAuth2Client } from "google-auth-library";

const SCOPES = ["https://www.googleapis.com/auth/calendar.readonly"];
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SCOPES only requests Calendar access, but the callback logic needs a stable user identifier (email) to upsert GoogleAccount. Add identity scopes (e.g. openid, email, and/or https://www.googleapis.com/auth/userinfo.email) and update the callback to use that identity data, otherwise account identification will be unreliable.

Suggested change
const SCOPES = ["https://www.googleapis.com/auth/calendar.readonly"];
const SCOPES = [
"openid",
"email",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/calendar.readonly",
];

Copilot uses AI. Check for mistakes.
Comment thread src/lib/google-auth.ts
Comment on lines +21 to +25
return client.generateAuthUrl({
access_type: "offline",
scope: SCOPES,
prompt: "consent", // always show consent to get refresh token
});
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The OAuth authorization URL is generated without a state parameter and there’s no corresponding validation in the callback, which enables CSRF/login-confusion attacks. Generate a random state, persist it (e.g. HttpOnly cookie), and validate it in the callback before exchanging the code.

Copilot uses AI. Check for mistakes.
Comment on lines +65 to +69
timeMax: future.toISOString(),
singleEvents: "true",
orderBy: "startTime",
maxResults: "100",
});
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Event fetch is limited to maxResults: "100" and does not paginate via nextPageToken. Calendars with >100 upcoming events will be partially synced, and any “delete missing” logic may remove valid events. Implement pagination (loop until no nextPageToken) or avoid deletion-by-diff unless you can guarantee a complete listing.

Copilot uses AI. Check for mistakes.
Comment on lines +88 to +90
if (dt.dateTime) return new Date(dt.dateTime);
if (dt.date) return new Date(`${dt.date}T12:00:00.000Z`);
return new Date();
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseGoogleDate() falls back to new Date() when neither dateTime nor date is present. That will create events at “now” rather than failing fast, which can lead to confusing/incorrect agenda entries. Prefer throwing/skipping the event if the start/end is missing or invalid, and log enough context to debug the upstream payload.

Suggested change
if (dt.dateTime) return new Date(dt.dateTime);
if (dt.date) return new Date(`${dt.date}T12:00:00.000Z`);
return new Date();
let parsed: Date | null = null;
if (dt.dateTime) {
parsed = new Date(dt.dateTime);
} else if (dt.date) {
parsed = new Date(`${dt.date}T12:00:00.000Z`);
} else {
console.error("Google Calendar event is missing both dateTime and date", {
dt,
});
throw new Error("Google Calendar event is missing a start/end date");
}
if (Number.isNaN(parsed.getTime())) {
console.error("Google Calendar event has an invalid date value", {
dt,
});
throw new Error("Google Calendar event contains an invalid start/end date");
}
return parsed;

Copilot uses AI. Check for mistakes.
import { NextResponse } from "next/server";
import { syncCalendarEvents } from "@/lib/google-calendar";

export async function GET() {
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GET /api/sync/calendar has no authentication/authorization checks, so any caller who can reach it can trigger external API calls and DB writes/deletes. Add an auth gate (e.g., validate the existing session cookie, or require a shared secret header/query param for cron).

Suggested change
export async function GET() {
export async function GET(request: Request) {
const configuredSecret = process.env.CALENDAR_SYNC_SECRET;
if (!configuredSecret) {
console.error("Calendar sync endpoint misconfigured: missing CALENDAR_SYNC_SECRET");
return NextResponse.json(
{ error: "Sync endpoint is not configured" },
{ status: 500 }
);
}
const authorizationHeader = request.headers.get("authorization");
const bearerToken = authorizationHeader?.startsWith("Bearer ")
? authorizationHeader.slice("Bearer ".length)
: null;
const headerSecret = request.headers.get("x-sync-secret");
const querySecret = new URL(request.url).searchParams.get("secret");
const providedSecret = bearerToken ?? headerSecret ?? querySecret;
if (providedSecret !== configuredSecret) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}

Copilot uses AI. Check for mistakes.
Comment thread src/proxy.ts
Comment on lines +16 to +17
pathname.startsWith("/favicon") ||
pathname.startsWith("/api/auth/google")
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pathname.startsWith("/api/auth/google") bypasses the session check for both the OAuth initiation and callback routes. If this middleware is active, it allows unauthenticated visitors to complete OAuth and persist tokens in GoogleAccount. Consider requiring an authenticated session for the initiation route (and ideally the callback too, validated via OAuth state tied to the session), or narrowing the allowlist to only what must be public.

Suggested change
pathname.startsWith("/favicon") ||
pathname.startsWith("/api/auth/google")
pathname.startsWith("/favicon")

Copilot uses AI. Check for mistakes.
@alecvdp
Copy link
Copy Markdown
Owner Author

alecvdp commented Apr 19, 2026

@copilot resolve the merge conflicts in this pull request

…flicts)

Co-authored-by: alecvdp <182568458+alecvdp@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 19, 2026

@copilot resolve the merge conflicts in this pull request

Resolved in 39e7315. The merge conflicts in EventsWidget.tsx and EventsWidget.module.css have been resolved, combining the Google Calendar sync UI (connection status, Google badge, read-only events) with the month/week calendar views from main. All 31 tests still pass and TypeScript compiles cleanly.

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • checkpoint.prisma.io
    • Triggering command: /opt/hostedtoolcache/node/24.14.1/x64/bin/node /opt/hostedtoolcache/node/24.14.1/x64/bin/node /home/REDACTED/work/us/us/node_modules/prisma/build/child {&#34;product&#34;:&#34;prisma&#34;,&#34;version&#34;:&#34;5.22.0&#34;,&#34;cli_install_type&#34;:&#34;local&#34;,&#34;information&#34;:&#34;&#34;,&#34;local_timestamp&#34;:&#34;2026-04-19T21:43:50Z&#34;,&#34;project_hash&#34;:&#34;4b39131e&#34;,&#34;cli_path&#34;:&#34;/home/REDACTED/work/us/us/node_modules/prisma/build/index.js&#34;,&#34;cli_path_hash&#34;:&#34;d0ed6219&#34;,&#34;endpo (dns block)
  • fonts.googleapis.com
    • Triggering command: /home/REDACTED/work/_temp/ghcca-node/node/bin/node node /home/REDACTED/work/us/us/node_modules/.bin/next build (dns block)

If you need me to access, download, or install something from one of these locations, you can either:

@alecvdp alecvdp merged commit f615b31 into main Apr 19, 2026
1 of 2 checks passed
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 8 additional findings in Devin Review.

Open in Devin Review

Comment thread src/proxy.ts
pathname.startsWith("/_next/") ||
pathname.startsWith("/favicon")
pathname.startsWith("/favicon") ||
pathname.startsWith("/api/auth/google")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Unauthenticated access to Google OAuth callback allows unauthorized account linking

The proxy at src/proxy.ts:17 exempts all paths starting with /api/auth/google from authentication. This covers both /api/auth/google (initiate OAuth) and /api/auth/google/callback (complete OAuth and store tokens). An unauthenticated attacker who can reach the app can complete the OAuth flow and insert a GoogleAccount record into the database, causing their Google Calendar events to appear on the dashboard. The /api/sync/calendar endpoint is correctly protected behind authentication, but the OAuth endpoints are not.

The callback endpoint at src/app/api/auth/google/callback/route.ts writes directly to the database via prisma.googleAccount.upsert, so bypassing auth here has real impact.

Prompt for agents
The proxy in src/proxy.ts allows unauthenticated access to all paths starting with /api/auth/google. While the initial OAuth redirect (/api/auth/google) could arguably be public, the callback (/api/auth/google/callback) writes tokens to the database and should require authentication.

Option 1: Only exempt the exact path /api/auth/google (not the callback) from the proxy. The callback URL will then require the session cookie. This works because the user initiates OAuth from the authenticated dashboard, and the callback redirect preserves their cookies.

Option 2: Add a CSRF state parameter to the OAuth flow. In the initiate endpoint, generate a random state value stored in a cookie, pass it to Google, then verify it in the callback before accepting the tokens. This is the standard OAuth security practice.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants