Skip to content

Fix Google Calendar: read-only, push, duplicates, and webhook issues - #488

Merged
2witstudios merged 3 commits into
masterfrom
fix/google-calendar-readonly-duplicates-webhooks
Feb 7, 2026
Merged

2witstudios merged 3 commits into
masterfrom
fix/google-calendar-readonly-duplicates-webhooks

Conversation

@2witstudios

@2witstudios 2witstudios commented Feb 7, 2026 •

Copy link
Copy Markdown
Owner

Summary

Fixes four interconnected issues with Google Calendar two-way sync introduced in PR #487:

  • Google-synced events are read-only when they should be editable — Users cannot edit or delete events that were synced from Google Calendar, even though two-way sync is the only mode
  • PageSpace events don't push to Google Calendar — Creating an event in PageSpace never creates it in Google, so two-way sync is effectively one-way
  • Duplicate events appear after sync — The same Google event shows up multiple times in the calendar view
  • Webhook registration fails noisily for holiday calendars — Logs fill with pushNotSupportedForRequestedResource errors every sync cycle

Root Causes & Fixes

1. Read-only + push broken (markAsReadOnly defaulted to true)

The DB schema defaults markAsReadOnly: true, so every connection starts read-only. The push service checked !connection.markAsReadOnly to decide whether to push, silently disabling all push. The event API routes blocked PATCH/DELETE on googleSyncReadOnly events.

Fix: Remove all read-only guards. Two-way sync is the only mode — pushEnabled is always true, event PATCH/DELETE no longer check googleSyncReadOnly, EventModal no longer shows read-only banner or disables fields. The OAuth callback now sets markAsReadOnly: false on reconnect. Sync always sets googleSyncReadOnly: false on every event update.

2. Duplicate events from alias mismatch

The upsert lookup used (createdById, googleEventId, googleCalendarId) as an exact-match key. Old events stored googleCalendarId = "primary" while new syncs resolve this to the actual email address, so lookups miss and create duplicates.

Fix: Two-step lookup — exact match first, then fallback to (createdById, googleEventId) alone. If the fallback finds a match with a stale googleCalendarId, it fixes it in place. A cleanup step at sync start also bulk-fixes all "primary" aliases to the resolved email.

3. Duplicate events from push/sync race condition

When a user creates an event, push sends it to Google via after() (fire-and-forget). If a webhook-triggered sync runs before push stores the googleEventId back on the local event, sync sees no googleEventId and creates a duplicate.

Fix: Near-duplicate detection before insert — if a local event exists with the same title and startAt within 1 minute, no googleEventId, and syncedFromGoogle = false, link it to the Google event instead of creating a new one.

4. Webhook noise for holiday calendars

registerWebhookChannels tried to register push notifications for ALL selected calendars, including Google's special calendars (holidays, contacts, week numbers) which don't support push.

Fix: Skip calendars matching #...@group.v.calendar.google.com with an info-level log instead of attempting registration and logging warnings.

5. Legacy data cleanup

Added an idempotent cleanupLegacyData() that runs at the start of every sync to fix existing data:

  • Sets markAsReadOnly = false on the connection
  • Sets googleSyncReadOnly = false on all synced events
  • Fixes googleCalendarId = "primary" → actual email
  • Deduplicates events sharing the same googleEventId (soft-deletes older copies)

Files Changed

File What changed
sync-service.ts Hardcode two-way, add cleanup, fix upsert lookup, add near-dup detection, filter webhooks
push-service.ts Always enable push (pushEnabled: true)
events/[eventId]/route.ts Remove read-only guards on PATCH and DELETE
callback/route.ts Add markAsReadOnly: false to reconnect upsert
settings/route.ts Remove markAsReadOnly from schema, GET, and PATCH
EventModal.tsx Remove read-only banner, fieldset disable, button disable

Test Plan

  • Read-only fix: Connect Google Calendar → sync → open a Google-synced event in EventModal → edit title and save → should succeed (no "read-only" error)
  • Push fix: Create a new event in PageSpace → check Google Calendar → event should appear within seconds
  • Duplicate fix (alias): After first sync, verify no duplicate events in calendar view. Check DB for events with googleCalendarId = 'primary' — should be zero
  • Duplicate fix (race): Create a PageSpace event, immediately trigger a manual sync → should still show only one copy
  • Webhook fix: Check logs after sync — no pushNotSupportedForRequestedResource errors for holiday calendars
  • Cleanup: After first sync post-deploy, verify old read-only events are now editable and old duplicates are trashed
  • Reconnect: Disconnect and reconnect Google Calendar → verify markAsReadOnly is false on the connection

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Google Calendar events can now be edited and deleted in the app (read-only restrictions removed).
    • New sync logic links near-duplicate local events to Google events and improves matching.
  • Bug Fixes

    • Reduced duplicate events and improved per-calendar sync reliability.
    • Webhook handling improved for special calendar types.
  • Chores

    • Read-only flags defaulted to editable and related settings removed from status/endpoints.

Remove read-only mode entirely (two-way sync is the only mode), fix
push-to-Google being silently disabled, prevent duplicate events from
alias mismatches and push/sync race conditions, and suppress noisy
webhook errors for holiday calendars.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@coderabbitai

coderabbitai Bot commented Feb 7, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Rate limit exceeded

@2witstudios has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 0 minutes and 6 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📝 Walkthrough

Walkthrough

Removes Google-readonly enforcement: deletes guards and schema surface for markAsReadOnly/googleSyncReadOnly, defaults those flags to false, updates sync/push logic and event transformation, adds legacy cleanup and deduplication, and removes UI/API blocking of edits for Google-synced events.

Changes

Cohort / File(s) Summary
API Event Routes
apps/web/src/app/api/calendar/events/[eventId]/route.ts, apps/web/src/app/api/calendar/events/[eventId]/attendees/route.ts
Removed Google-readonly guards and GOOGLE_READ_ONLY_ERROR checks that previously returned 403 for PATCH/DELETE and attendee operations.
Integration Connect / Settings / Status
apps/web/src/app/api/integrations/google-calendar/callback/route.ts, apps/web/src/app/api/integrations/google-calendar/settings/route.ts, apps/web/src/app/api/integrations/google-calendar/status/route.ts, apps/web/src/app/api/integrations/google-calendar/disconnect/route.ts
callback now upserts markAsReadOnly: false; settings/status API no longer expose or accept markAsReadOnly; disconnect removed the update that reset event read-only flags.
UI
apps/web/src/components/calendar/EventModal.tsx, apps/web/src/app/settings/integrations/google-calendar/page.tsx
Removed UI logic, alerts, and form disabling tied to Google-readonly; removed markAsReadOnly from connection type used by settings page.
Sync & Push Services
apps/web/src/lib/integrations/google-calendar/sync-service.ts, apps/web/src/lib/integrations/google-calendar/push-service.ts, apps/web/src/lib/integrations/google-calendar/event-transform.ts
Sync-service: added legacy cleanup, deduplication, near-duplicate linking, always sets googleSyncReadOnly=false on upserts, skips webhook setup for special calendars. Push-service: pushEnabled no longer reads markAsReadOnly (hardcoded true). Event transform: removed markAsReadOnly param and sets googleSyncReadOnly=false.
AI Tools & Helpers
apps/web/src/lib/ai/tools/calendar-write-tools.ts
Removed read-only enforcement checks so calendar write tools no longer block edits based on Google-sync flags.
DB schema & migration
packages/db/src/schema/calendar.ts, packages/db/drizzle/0075_opposite_starhawk.sql, packages/db/drizzle/meta/_journal.json
Changed defaults: calendar_events.googleSyncReadOnly and google_calendar_connections.markAsReadOnly default to false; added migration and journal entry.
Tests
apps/web/src/lib/integrations/google-calendar/__tests__/sync-service.test.ts
Updated mocks (isNull, desc) and test fixtures to reflect markAsReadOnly: false defaults.

Sequence Diagram(s)

sequenceDiagram
participant Client
participant API as Server API
participant DB
participant Google as Google Calendar API
participant Push as PushService

Client->>API: Request to create/update/delete event
API->>DB: Validate & fetch event/connection
DB-->>API: Event & connection data
API->>Push: Broadcast change (webhook/push)
Push->>Google: (if applicable) send push/notify
API->>DB: Upsert/update event record (googleSyncReadOnly=false)
DB-->>API: Confirmation
API-->>Client: Success response
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested labels

codex

Poem

🐰 I hopped through code at break of day,

Read-only walls I pushed away,
Two-way sync now hops and sings,
Events unite with open wings,
A carrot-toast to freer plays! 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately captures the main focus: fixing Google Calendar read-only, push, duplicates, and webhook issues. It directly corresponds to the primary changes across all modified files.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/google-calendar-readonly-duplicates-webhooks

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

…ter threading, and schema defaults

Two-way sync is now the only mode. Remove GOOGLE_READ_ONLY_ERROR constants,
read-only guard blocks, markAsReadOnly parameter threading through sync/transform
functions, and flip schema defaults to false. Gate cleanupLegacyData to run once
per connection. Fix pre-existing test mock (missing isNull/desc exports).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@apps/web/src/lib/integrations/google-calendar/sync-service.ts`:
- Around line 491-526: The code sets syncedFromGoogle: true when linking a local
event to an incoming Google event, which prevents the push-service
(push-service.ts) from ever pushing local edits back; instead, update the update
block in sync-service.ts (the one updating calendarEvents for nearDuplicate.id
with googleEventId and googleCalendarId) to NOT set syncedFromGoogle to true
(leave it false or omit it) and ensure googleSyncReadOnly is false and
lastGoogleSync/updatedAt are set appropriately so the event stays pushable; keep
mapAttendeesToUsers(nearDuplicate.id, googleEvent.attendees) as-is.
🧹 Nitpick comments (3)
apps/web/src/lib/integrations/google-calendar/__tests__/sync-service.test.ts (1)

260-261: Consider adding test coverage for the new sync-service behaviors.

The PR introduces significant new logic — cleanupLegacyData(), near-duplicate detection (title + startAt within 1 min), and calendar ID alias correction — but this test file has no coverage for any of it. These are complex conditional paths that would benefit from dedicated test cases to prevent silent regressions.

apps/web/src/lib/integrations/google-calendar/sync-service.ts (2)

221-224: Stale docstring: says "runs every sync" but the gate skips it after first run.

Line 223 says "runs every sync but is a no-op after first run," but the call site (Line 115) gates on connection.markAsReadOnly, so the function is never invoked again once the flag is cleared.

📝 Suggested fix
 /**
- * Fix legacy data from when read-only mode was the default.
- * Idempotent: runs every sync but is a no-op after first run.
+ * Fix legacy data from when read-only mode was the default.
+ * Gated by markAsReadOnly on the connection — runs once, then skipped on subsequent syncs.
  */

257-268: Raw SQL used for deduplication query — consider using Drizzle query builder.

The coding guidelines require using Drizzle ORM for all database operations. While db.execute(sql...) is still going through Drizzle, this raw query is fragile against table/column renames and skips Drizzle's type-safe ergonomics. A GROUP BY + HAVING can be expressed via Drizzle's select().from().where().groupBy().having() API.

♻️ Drizzle query builder alternative
-  const duplicateResult = await db.execute<{
-    google_event_id: string;
-    cnt: string;
-  }>(sql`
-    SELECT "googleEventId" as google_event_id, COUNT(*) as cnt
-    FROM calendar_events
-    WHERE "createdById" = ${userId}
-      AND "googleEventId" IS NOT NULL
-      AND "isTrashed" = false
-    GROUP BY "googleEventId"
-    HAVING COUNT(*) > 1
-  `);
+  const duplicateResult = await db
+    .select({
+      googleEventId: calendarEvents.googleEventId,
+      cnt: sql<number>`count(*)`.as('cnt'),
+    })
+    .from(calendarEvents)
+    .where(and(
+      eq(calendarEvents.createdById, userId),
+      sql`${calendarEvents.googleEventId} IS NOT NULL`,
+      eq(calendarEvents.isTrashed, false)
+    ))
+    .groupBy(calendarEvents.googleEventId)
+    .having(sql`count(*) > 1`);

Then update the loop to use row.googleEventId instead of row.google_event_id.

As per coding guidelines: "Use centralized Drizzle ORM with PostgreSQL for all database operations - no direct SQL or other ORMs."

Comment thread apps/web/src/lib/integrations/google-calendar/sync-service.ts
When linking a locally-created event to its Google counterpart during
sync (race condition guard), keep syncedFromGoogle=false so the push
service continues pushing local edits back to Google. Setting it to
true would silently break two-way sync for the affected event.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@2witstudios
2witstudios merged commit c2aa5c7 into master Feb 7, 2026
3 checks passed
@2witstudios
2witstudios deleted the fix/google-calendar-readonly-duplicates-webhooks branch March 11, 2026 03:13
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.

1 participant