Skip to content

Comments

feat: Calendar Cache and Sync#23675

Closed
volnei wants to merge 17 commits intomainfrom
feat/calendar-sync
Closed

feat: Calendar Cache and Sync#23675
volnei wants to merge 17 commits intomainfrom
feat/calendar-sync

Conversation

@volnei
Copy link
Contributor

@volnei volnei commented Sep 8, 2025

What does this PR do?

This PR introduces a comprehensive Calendar Cache and Sync system that enables real-time synchronization and local caching of external calendar events from providers like Google Calendar and Outlook.

Key Features

🔄 Real-time Calendar Synchronization

  • Implements webhook-based subscriptions to external calendar providers
  • Enables bidirectional sync between Cal.com and external calendars
  • Supports both Google Calendar and Outlook (with extensible adapter pattern)

📦 Calendar Event Caching

  • Local caching of external calendar events for improved performance
  • Reduces API calls to external providers
  • Enables offline access to calendar data

🏗️ Modular Architecture

  • Clean separation of concerns with service layer, adapters, and repositories
  • Provider-agnostic interface (ICalendarSubscriptionPort) for easy extension
  • Dedicated services for subscription management, caching, and synchronization

Technical Implementation

New Database Schema:

  • CalendarCacheEvent table to store cached calendar events with full event metadata
  • Extended SelectedCalendar table with subscription tracking fields (channelId, syncEnabled, cacheEnabled, etc.)

Service Architecture:

  • CalendarSubscriptionService - Manages calendar subscriptions and webhook processing
  • CalendarCacheEventService - Handles event caching operations
  • CalendarSyncService - Manages bidirectional synchronization (planned)
  • Provider-specific adapters (GoogleCalendarSubscriptionAdapter, OutlookCalendarSubscriptionAdapter)

Webhook Infrastructure:

  • New API endpoint /api/webhook/calendar/[provider]/route.ts for handling provider webhooks
  • Secure webhook validation with configurable tokens
  • Automatic subscription renewal and management

Configuration

New Environment Variables:

GOOGLE_WEBHOOK_TOKEN=your_webhook_token
GOOGLE_WEBHOOK_URL=https://your-app.com/api/webhook/calendar/google
MICROSOFT_WEBHOOK_TOKEN=your_webhook_token
MICROSOFT_WEBHOOK_URL=https://your-app.com/api/webhook/calendar/microsoft

Feature Flags:

  • calendar-subscription-cache - Enable/disable calendar event caching
  • calendar-subscription-sync - Enable/disable calendar synchronization

Database Changes

  • Migration: 20250904193813_add_calendar_cache_and_sync
  • New Table: CalendarCacheEvent with comprehensive event metadata
  • Schema Updates: Extended SelectedCalendar with subscription management fields
  • Optimized Indexing: Performance-focused indexes for calendar queries

Files Added/Modified

  • New feature package: packages/features/calendar-subscription/
  • Database migration and schema updates
  • Webhook handlers and API endpoints
  • Repository and service implementations

Future Enhancements

The architecture supports planned features like:

  • Automatic booking conflict resolution
  • Calendar-based availability updates
  • Enhanced event synchronization workflows
  • Support for additional calendar providers

This foundational implementation establishes the infrastructure for advanced calendar integration capabilities while maintaining performance and reliability.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Sep 8, 2025

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Walkthrough

This PR introduces a calendar subscription system. It adds provider-agnostic interfaces, adapters for Google and Microsoft (Office 365), an AdapterFactory, and a CalendarSubscriptionService orchestrating subscribe/unsubscribe, webhook processing, and event handling. It adds cache and sync services, repositories, and Prisma schema/migration for SelectedCalendar fields and a new CalendarCacheEvent model. A Next.js App Router POST endpoint processes webhooks per provider, with comprehensive unit and integration tests. New feature flags and environment variables are added. Minor updates include removing a cron tester call and adjusting VS Code spell checking.

Possibly related PRs

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Title Check ✅ Passed The PR title "feat: Calendar Cache and Sync" is concise, follows conventional commit style, and accurately summarizes the primary change set which implements calendar caching and synchronization infrastructure (services, adapters, webhook endpoints, and DB migration). It directly relates to the files and features changed and conveys the main intent to reviewers scanning history. The title contains no misleading details or unnecessary noise.
Description Check ✅ Passed The PR description is comprehensive and clearly documents the calendar cache and sync functionality, configuration (env vars and feature flags), database migration, and the main files and services added, which matches the provided changeset summary. It describes the new services, adapters, webhook endpoints, and migration intent in a way that is relevant to reviewers and not off-topic. Given the lenient criteria for this check, the description sufficiently relates to the changeset.

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.

@github-actions github-actions bot added ❗️ migrations contains migration files ❗️ .env changes contains changes to env variables labels Sep 8, 2025
@keithwillcode keithwillcode added core area: core, team members only foundation labels Sep 8, 2025
"typescript.preferences.importModuleSpecifier": "non-relative",
"spellright.language": ["en"],
"spellright.documentTypes": ["markdown", "typescript", "typescriptreact"],
"spellright.documentTypes": ["markdown", "typescriptreact"],
Copy link
Contributor Author

Choose a reason for hiding this comment

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

remove

@vercel
Copy link

vercel bot commented Sep 8, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

2 Skipped Deployments
Project Deployment Preview Comments Updated (UTC)
cal Ignored Ignored Sep 16, 2025 5:12pm
cal-eu Ignored Ignored Sep 16, 2025 5:12pm

- Add unit tests for CalendarSubscriptionService with webhook processing
- Add unit tests for CalendarCacheEventService and Repository
- Add unit tests for Google and Microsoft calendar adapters
- Add integration tests for webhook processing flow
- Add tests for webhook API route handlers
- Mock external API calls and database operations using prismock and vi.fn()
- Cover error scenarios and edge cases for all components
- Fix event filtering in adapters to exclude Cal.com events
- Replace all 'any' types with proper TypeScript types for lint compliance
- All 62 tests passing with comprehensive coverage

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>
@volnei
Copy link
Contributor Author

volnei commented Sep 15, 2025

@coderabbitai review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Sep 15, 2025

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 30

♻️ Duplicate comments (1)
.vscode/settings.json (1)

9-9: Spellright scope change looks good.

Dropping "typescript" aligns with the prior request. Confirm this is intentional so .ts files aren’t spell‑checked.

🧹 Nitpick comments (30)
apps/web/cron-tester.ts (1)

25-25: Guard against overlapping runs and hung fetches.

CronJob doesn’t await your async callback; overlapping executions and indefinite fetch hangs are possible. Add a reentrancy guard and per‑request timeout.

-      await Promise.allSettled([fetchCron("/calendar-cache/cron"), fetchCron("/tasks/cron")]);
+      if (running) {
+        console.warn("⏭️ Previous cron still running, skipping this tick");
+        return;
+      }
+      running = true;
+      try {
+        await Promise.allSettled([fetchCron("/calendar-cache/cron"), fetchCron("/tasks/cron")]);
+      } finally {
+        running = false;
+      }

Outside this hunk, update helpers:

// top-level
let running = false;

async function fetchCron(endpoint: string) {
  const apiKey = process.env.CRON_API_KEY;
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 10_000);
  try {
    const res = await fetch(`http://localhost:3000/api${endpoint}?apiKey=${apiKey}`, {
      headers: { "Content-Type": "application/json", authorization: `Bearer ${process.env.CRON_SECRET}` },
      signal: controller.signal,
    });
    const body = res.headers.get("content-type")?.includes("application/json") ? await res.json() : await res.text();
    console.log(endpoint, res.status, body);
  } finally {
    clearTimeout(timeout);
  }
}
packages/features/calendar-subscription/README.md (1)

1-10: Fill in README with flag names, routes, and setup steps.

Add: feature flags (calendar-sync, calendar-subscription-cache, calendar-subscription-sync), required env vars, webhook endpoints per provider, subscription lifecycle, and local dev tunneling instructions.

.env.example (1)

142-149: Tighten formatting and clarify secrets guidance.

Remove the extra blank line (dotenv-linter warning) and keep sections contiguous.

 GOOGLE_WEBHOOK_URL=
-
 # - MICROSOFT WEBHOOKS *****************************************************************************************

Also add a brief “Do not commit real tokens” note under both webhook sections.

Also applies to: 151-159

packages/features/calendar-subscription/lib/sync/CalendarSyncService.ts (1)

28-39: Make cal.com domain filter configurable and gate execution by flag.

Parameterize "@cal.com" (env/config) and ensure callers gate handleEvents behind "calendar-subscription-sync".

-    const calEvents = calendarSubscriptionEvents.filter((e) =>
-      e.iCalUID?.toLowerCase()?.endsWith("@cal.com")
-    );
+    const domain = process.env.CAL_ICAL_DOMAIN?.toLowerCase() ?? "@cal.com";
+    const calEvents = calendarSubscriptionEvents.filter((e) =>
+      e.iCalUID?.toLowerCase()?.endsWith(domain)
+    );
packages/app-store/office365calendar/zod.ts (1)

13-31: clientState verification present; recommend parsing subscriptionExpirationDateTime to Date

  • clientState is validated against MICROSOFT_WEBHOOK_TOKEN in packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts (handler ~lines 74–95; subscription creation at line 129). Tests include clientState (apps/web/app/api/webhooks/calendar/[provider]/route.test.ts, packages/features/calendar-subscription/lib/integration.test.ts).
  • Keep clientState optional in the zod schema (Graph may omit it). Optionally transform/parse subscriptionExpirationDateTime to a Date upstream (zod transform or parse in the handler) before downstream use.
packages/prisma/schema.prisma (2)

2563-2603: Add query‑pattern indexes for cache reads and incremental sync.

Typical access patterns: by selectedCalendarId + time range and by selectedCalendarId + externalUpdatedAt.

@@
   @@unique([selectedCalendarId, externalId])
   @@index([start, end, status])
   @@index([selectedCalendarId, iCalUID])
+  @@index([selectedCalendarId, start])
+  @@index([selectedCalendarId, externalUpdatedAt])

2568-2570: Make externalEtag nullable (some providers don’t guarantee ETag).

If adapters can’t always supply ETag, make it optional to avoid failed upserts.

-  externalEtag String
+  externalEtag String?
packages/features/calendar-subscription/lib/integration.test.ts (3)

30-43: Assert feature flag expectations with explicit boolean casts.

Minor: vi.fn().mockResolvedValueOnce(true).mockResolvedValueOnce(false) is fine; consider asserting call order with toHaveBeenNthCalledWith for clarity.

Also applies to: 217-247, 331-360


30-42: Optional: tighten repository mock types.

If SelectedCalendarRepository is an interface, cast mocks with vi.mocked to get type‑safe spies.


51-71: DRY test setup with a factory for service/adapters.

Reduce duplication between Google/Microsoft suites.

Also applies to: 177-185, 293-301

apps/web/app/api/webhooks/calendar/[provider]/route.ts (1)

16-26: Docstring promises 400 for unsupported provider, but code never returns it.

Covered by provider allowlist fix above.

packages/features/calendar-subscription/adapters/AdaptersFactory.ts (1)

1-7: Consistent naming: Microsoft vs Office365.

Adapters are named Office365* here; tests import Microsoft*. Pick one convention and stick with it to avoid confusion.

apps/web/app/api/webhooks/calendar/[provider]/route.test.ts (1)

74-74: Add GET validation handshake test for Microsoft Graph

Graph sends a GET with validationToken during subscription creation. Please add a test ensuring the route echoes the token with 200 and proper headers.

I can draft the test once the GET handler is in place.

packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.interface.ts (1)

11-17: Clarify upsert contract and required keys

The interface says “upsert” with Partial<CalendarCacheEvent>, but the implementation uses createMany with full entities. Define the required unique keys (likely externalId + selectedCalendarId) and ensure the implementation truly upserts.

Would you like me to align the implementation to this contract using transactional per-row upserts?

packages/lib/server/repository/SelectedCalendarRepository.ts (2)

20-25: Consider scoping “not subscribed” to enabled calendars

Right now it pulls all with syncSubscribedAt: null. If syncEnabled/cacheEnabled flags exist, filter here to avoid unnecessary subscribe attempts.

-    return await this.prismaClient.selectedCalendar.findMany({
-      where: { syncSubscribedAt: null },
+    return await this.prismaClient.selectedCalendar.findMany({
+      where: { syncSubscribedAt: null, syncEnabled: true },
       take,
     });

27-29: Index/channel uniqueness

Ensure channelId has an index/unique constraint to keep findFirst({ where: { channelId } }) fast and unambiguous.

packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts (3)

48-56: Reset error state on successful subscribe

After a successful subscribe, clear previous sync errors for a clean slate.

     await this.deps.selectedCalendarRepository.updateById(selectedCalendarId, {
       channelId: res?.id,
       channelResourceId: res?.resourceId,
       channelResourceUri: res?.resourceUri,
       channelKind: res?.provider,
       channelExpiration: res?.expiration,
       syncSubscribedAt: new Date(),
+      syncErrorAt: null,
+      syncErrorCount: 0,
     });

132-141: Inject cache repository instead of constructing with global prisma

DI here improves testability and avoids hard-coding a global client. Pass calendarCacheEventRepository via ctor deps; the route can wire Prisma-backed impl.

If you want, I can provide a small refactor patch across service wiring and route.


100-103: Log message clarity

This branch also hits when features are disabled. Tweak the log to avoid “not found” confusion.

-      log.debug("Selected calendar not found", { channelId });
+      log.debug("Calendar disabled or missing credential; skipping", { channelId, cacheEnabled, syncEnabled });
packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts (2)

53-53: Fix inconsistent field mapping between event and cache model.

The code maps event.originalStartDate to originalStartTime, but the field name suggests this should be event.originalStartTime for consistency.

Apply this diff to fix the field mapping:

-          originalStartTime: event.originalStartDate,
+          originalStartTime: event.originalStartTime,

70-73: Consider error handling for Promise.allSettled results.

Using Promise.allSettled is good for resilience, but the code doesn't check if operations failed. Database errors could silently fail without proper monitoring or recovery.

Consider logging failures and potentially implementing retry logic:

-    await Promise.allSettled([
-      this.deps.calendarCacheEventRepository.deleteMany(toDelete),
-      this.deps.calendarCacheEventRepository.upsertMany(toUpsert),
-    ]);
+    const results = await Promise.allSettled([
+      this.deps.calendarCacheEventRepository.deleteMany(toDelete),
+      this.deps.calendarCacheEventRepository.upsertMany(toUpsert),
+    ]);
+    
+    results.forEach((result, index) => {
+      if (result.status === 'rejected') {
+        const operation = index === 0 ? 'deleteMany' : 'upsertMany';
+        log.error(`Failed to ${operation} cache events`, { 
+          error: result.reason,
+          count: index === 0 ? toDelete.length : toUpsert.length 
+        });
+      }
+    });
packages/app-store/googlecalendar/api/subscriptions/webhook.handler.ts (2)

56-56: Remove unnecessary line.

Line 56 contains just selectedCalendar; which serves no purpose.

-    selectedCalendar;

92-107: Consider stricter data validation with dedicated error type.

The data inconsistency check between SelectedCalendar.externalId and CalendarSync.externalCalendarId throws a generic Error. Consider using a specific error type for better error handling downstream.

+class DataInconsistencyError extends Error {
+  constructor(message: string, public context: Record<string, unknown>) {
+    super(message);
+    this.name = "DataInconsistencyError";
+  }
+}

     if (externalCalendarId && externalCalendarId !== calendarSync.externalCalendarId) {
       log.error(
         "Data inconsistency: Selected calendar externalId and Synced calendar externalId do not match for the same subscription.",
         safeStringify({
           channelId,
           resourceId,
           selectedExternalId: externalCalendarId,
           syncedExternalId: calendarSync.externalCalendarId,
           selectedCalendarId: selectedCalendar?.id,
           calendarSyncId: calendarSync.id,
         })
       );

-      throw new Error(
-        "Data inconsistency: SelectedCalendar.externalId and CalendarSync.externalCalendarId do not match for the same subscription."
+      throw new DataInconsistencyError(
+        "Data inconsistency: SelectedCalendar.externalId and CalendarSync.externalCalendarId do not match for the same subscription.",
+        {
+          channelId,
+          resourceId,
+          selectedExternalId: externalCalendarId,
+          syncedExternalId: calendarSync.externalCalendarId,
+        }
       );
     }
packages/features/calendar-subscription/adapters/GoogleCalendarSubscription.adapter.ts (1)

56-56: Remove unnecessary standalone statement.

Line 56 contains a standalone selectedCalendar; statement that serves no purpose.

     log.debug("Attempt to subscribe to Google Calendar", { externalId: selectedCalendar.externalId });
-    selectedCalendar;
packages/prisma/migrations/20250915124730_add_calendar_sync_and_cache/migration.sql (1)

18-18: Consider making externalEtag nullable.

The externalEtag field is defined as NOT NULL, but not all calendar providers may provide ETags. Consider making it nullable to support provider flexibility.

-    "externalEtag" TEXT NOT NULL,
+    "externalEtag" TEXT,
packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts (5)

56-57: Translation needed for Portuguese comment.

The comment on line 56 is in Portuguese. For better maintainability and team collaboration, use English for all code comments.

-  // duração padrão ~3 dias (Graph ~4230 min)
+  // default duration ~3 days (Graph ~4230 min)

144-150: Missing error handling for subscription deletion.

The unsubscribe method should handle cases where the subscription deletion fails (e.g., already deleted, network issues). Currently, any error will bubble up uncaught.

 async unsubscribe(selectedCalendar: SelectedCalendar, credential: CalendarCredential): Promise<void> {
   const subId = selectedCalendar.channelResourceId;
   if (!subId) return;

   const client = await this.getGraphClient(credential);
-  await this.request(client, "DELETE", `/subscriptions/${subId}`);
+  try {
+    await this.request(client, "DELETE", `/subscriptions/${subId}`);
+  } catch (error: any) {
+    // 404 means subscription already deleted, which is acceptable
+    if (error.message?.includes("404")) {
+      log.info("Subscription already deleted", { subId });
+      return;
+    }
+    throw error;
+  }
 }

241-245: Sensitive data potentially logged in error messages.

The error logging on line 243 logs the full response text which might contain sensitive information like PII or authentication details. Consider sanitizing or limiting the logged content.

 if (!res.ok) {
   const text = await res.text().catch(() => "");
-  log.error("Graph API error", { method, endpoint: this.stripBase(url), status: res.status, text });
+  // Limit logged text to prevent sensitive data exposure
+  const truncatedText = text.length > 200 ? text.substring(0, 200) + "..." : text;
+  log.error("Graph API error", { 
+    method, 
+    endpoint: this.stripBase(url), 
+    status: res.status, 
+    text: truncatedText 
+  });
   throw new Error(`Graph ${res.status} ${res.statusText}`);
 }

234-237: Add request timeout configuration.

The fetch request lacks a timeout configuration, which could lead to hanging requests if the Microsoft Graph API is slow or unresponsive.

 const init: RequestInit = { method, headers };
 if (data && (method === "POST" || method === "PUT" || method === "PATCH")) {
   init.body = JSON.stringify(data);
 }
+
+// Add timeout using AbortController
+const controller = new AbortController();
+const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout
+init.signal = controller.signal;

-const res = await fetch(url, init);
+try {
+  const res = await fetch(url, init);
+  clearTimeout(timeoutId);
+  
+  if (!res.ok) {
+    const text = await res.text().catch(() => "");
+    const truncatedText = text.length > 200 ? text.substring(0, 200) + "..." : text;
+    log.error("Graph API error", { 
+      method, 
+      endpoint: this.stripBase(url), 
+      status: res.status, 
+      text: truncatedText 
+    });
+    throw new Error(`Graph ${res.status} ${res.statusText}`);
+  }
+  
+  if (method === "DELETE" || res.status === 204) return {} as T;
+  
+  return (await res.json()) as T;
+} catch (error: any) {
+  clearTimeout(timeoutId);
+  if (error.name === 'AbortError') {
+    throw new Error(`Graph API request timeout: ${this.stripBase(url)}`);
+  }
+  throw error;
+}

73-77: Validate environment variables on initialization.

The constructor should validate that required environment variables are present and properly formatted to fail fast rather than at runtime.

 constructor(cfg: AdapterConfig = {}) {
   this.baseUrl = cfg.baseUrl ?? "https://graph.microsoft.com/v1.0";
   this.webhookToken = cfg.webhookToken ?? process.env.MICROSOFT_WEBHOOK_TOKEN ?? null;
   this.webhookUrl = cfg.webhookUrl ?? process.env.MICROSOFT_WEBHOOK_URL ?? null;
   this.subscriptionTtlMs = cfg.subscriptionTtlMs ?? 3 * 24 * 60 * 60 * 1000;
+  
+  // Validate webhook URL format if provided
+  if (this.webhookUrl) {
+    try {
+      new URL(this.webhookUrl);
+    } catch (error) {
+      throw new Error(`Invalid MICROSOFT_WEBHOOK_URL format: ${this.webhookUrl}`);
+    }
+  }
 }
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between c28eb90 and c917428.

📒 Files selected for processing (24)
  • .env.example (1 hunks)
  • .vscode/settings.json (1 hunks)
  • apps/web/app/api/webhooks/calendar/[provider]/route.test.ts (1 hunks)
  • apps/web/app/api/webhooks/calendar/[provider]/route.ts (1 hunks)
  • apps/web/cron-tester.ts (1 hunks)
  • packages/app-store/googlecalendar/api/subscriptions/webhook.handler.ts (1 hunks)
  • packages/app-store/office365calendar/zod.ts (1 hunks)
  • packages/features/calendar-subscription/README.md (1 hunks)
  • packages/features/calendar-subscription/adapters/AdaptersFactory.ts (1 hunks)
  • packages/features/calendar-subscription/adapters/GoogleCalendarSubscription.adapter.ts (1 hunks)
  • packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts (1 hunks)
  • packages/features/calendar-subscription/lib/CalendarSubscriptionFeatures.interface.ts (1 hunks)
  • packages/features/calendar-subscription/lib/CalendarSubscriptionPort.interface.ts (1 hunks)
  • packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts (1 hunks)
  • packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.interface.ts (1 hunks)
  • packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts (1 hunks)
  • packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts (1 hunks)
  • packages/features/calendar-subscription/lib/integration.test.ts (1 hunks)
  • packages/features/calendar-subscription/lib/sync/CalendarSyncService.ts (1 hunks)
  • packages/features/flags/config.ts (2 hunks)
  • packages/lib/server/repository/SelectedCalendarRepository.ts (1 hunks)
  • packages/prisma/migrations/20250915124730_add_calendar_sync_and_cache/migration.sql (1 hunks)
  • packages/prisma/schema.prisma (4 hunks)
  • turbo.json (2 hunks)
🧰 Additional context used
📓 Path-based instructions (1)
**/*.{ts,tsx,js,jsx}

⚙️ CodeRabbit configuration file

Flag default exports and encourage named exports. Named exports provide better tree-shaking, easier refactoring, and clearer imports. Exempt main components like pages, layouts, and components that serve as the primary export of a module.

Files:

  • apps/web/app/api/webhooks/calendar/[provider]/route.test.ts
  • packages/features/calendar-subscription/lib/integration.test.ts
  • apps/web/cron-tester.ts
  • packages/features/calendar-subscription/lib/sync/CalendarSyncService.ts
  • packages/features/calendar-subscription/adapters/AdaptersFactory.ts
  • packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.interface.ts
  • packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts
  • packages/lib/server/repository/SelectedCalendarRepository.ts
  • packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts
  • packages/features/flags/config.ts
  • apps/web/app/api/webhooks/calendar/[provider]/route.ts
  • packages/features/calendar-subscription/lib/CalendarSubscriptionPort.interface.ts
  • packages/features/calendar-subscription/adapters/GoogleCalendarSubscription.adapter.ts
  • packages/features/calendar-subscription/lib/CalendarSubscriptionFeatures.interface.ts
  • packages/app-store/office365calendar/zod.ts
  • packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts
  • packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts
  • packages/app-store/googlecalendar/api/subscriptions/webhook.handler.ts
🧠 Learnings (10)
📓 Common learnings
Learnt from: vijayraghav-io
PR: calcom/cal.com#16878
File: packages/app-store/feishucalendar/api/callback.ts:72-79
Timestamp: 2025-09-08T07:27:42.903Z
Learning: Four calendar integrations in Cal.com still use direct prisma.selectedCalendar.create instead of SelectedCalendarRepository.create: feishucalendar, zohocalendar, office365calendar, and larkcalendar. These bypass repository hooks and won't trigger reconnection logic for BookingReferences.
📚 Learning: 2025-08-05T12:04:29.037Z
Learnt from: din-prajapati
PR: calcom/cal.com#21854
File: packages/app-store/office365calendar/__tests__/unit_tests/SubscriptionManager.test.ts:0-0
Timestamp: 2025-08-05T12:04:29.037Z
Learning: In packages/app-store/office365calendar/lib/CalendarService.ts, the fetcher method in Office365CalendarService class is public, not private. It was specifically changed from private to public in this PR to support proper testing and external access patterns.

Applied to files:

  • apps/web/cron-tester.ts
  • packages/features/calendar-subscription/lib/sync/CalendarSyncService.ts
  • packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts
  • packages/features/calendar-subscription/lib/CalendarSubscriptionFeatures.interface.ts
  • packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts
  • packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts
📚 Learning: 2025-09-08T07:27:42.903Z
Learnt from: vijayraghav-io
PR: calcom/cal.com#16878
File: packages/app-store/feishucalendar/api/callback.ts:72-79
Timestamp: 2025-09-08T07:27:42.903Z
Learning: In the Cal.com codebase, some calendar integrations like google-calendar already use SelectedCalendarRepository.create for selectedCalendar creation, which automatically triggers reconnection logic, while others like feishucalendar use direct prisma.selectedCalendar.create calls that bypass the repository hooks.

Applied to files:

  • packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.interface.ts
  • packages/lib/server/repository/SelectedCalendarRepository.ts
  • packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts
📚 Learning: 2025-07-18T17:57:16.395Z
Learnt from: vijayraghav-io
PR: calcom/cal.com#21072
File: packages/app-store/office365calendar/api/webhook.ts:120-123
Timestamp: 2025-07-18T17:57:16.395Z
Learning: The office365calendar webhook handler in packages/app-store/office365calendar/api/webhook.ts is specifically designed for Office365 calendar integration, not as a generic webhook handler. Therefore, it's safe to assume that fetchAvailabilityAndSetCache method will be implemented in the Office365CalendarService, making explicit validation checks unnecessary.

Applied to files:

  • packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts
  • packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts
  • packages/app-store/office365calendar/zod.ts
  • packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts
  • packages/app-store/googlecalendar/api/subscriptions/webhook.handler.ts
📚 Learning: 2025-09-08T07:27:42.903Z
Learnt from: vijayraghav-io
PR: calcom/cal.com#16878
File: packages/app-store/feishucalendar/api/callback.ts:72-79
Timestamp: 2025-09-08T07:27:42.903Z
Learning: Four calendar integrations in Cal.com still use direct prisma.selectedCalendar.create instead of SelectedCalendarRepository.create: feishucalendar, zohocalendar, office365calendar, and larkcalendar. These bypass repository hooks and won't trigger reconnection logic for BookingReferences.

Applied to files:

  • packages/lib/server/repository/SelectedCalendarRepository.ts
📚 Learning: 2025-09-09T03:29:43.025Z
Learnt from: emrysal
PR: calcom/cal.com#23692
File: packages/lib/server/service/InsightsBookingBaseService.ts:16-16
Timestamp: 2025-09-09T03:29:43.025Z
Learning: In the Cal.com codebase, readonlyPrisma is still an instance of PrismaClient, making type changes from `typeof readonlyPrisma` to `PrismaClient` less critical since they are fundamentally compatible types.

Applied to files:

  • packages/lib/server/repository/SelectedCalendarRepository.ts
📚 Learning: 2025-08-21T12:28:42.018Z
Learnt from: alishaz-polymath
PR: calcom/cal.com#23247
File: packages/features/webhooks/lib/factory/WebhookPayloadFactory.ts:274-282
Timestamp: 2025-08-21T12:28:42.018Z
Learning: In webhook DTOs in packages/features/webhooks/lib/dto/types.ts, the booking fields are restricted structures containing only specific fields (id, eventTypeId, userId, and sometimes additional fields like startTime or smsReminderNumber) rather than full database booking objects, so there are no security or PII leakage concerns when using these booking objects in webhook payloads.

Applied to files:

  • packages/app-store/office365calendar/zod.ts
📚 Learning: 2025-07-18T08:47:01.264Z
Learnt from: vijayraghav-io
PR: calcom/cal.com#21072
File: packages/prisma/schema.prisma:891-891
Timestamp: 2025-07-18T08:47:01.264Z
Learning: In Cal.com's calendar integration, both Google Calendar and Outlook Calendar are designed to allow multiple eventTypeIds to share the same subscription ID (googleChannelId or outlookSubscriptionId). This is an intentional design pattern to reuse existing subscriptions for efficiency rather than creating separate subscriptions for each event type. Therefore, unique constraints like `@unique([outlookSubscriptionId, eventTypeId])` should not be added as they would break this subscription sharing functionality.

Applied to files:

  • packages/prisma/schema.prisma
📚 Learning: 2025-07-18T08:47:01.264Z
Learnt from: vijayraghav-io
PR: calcom/cal.com#21072
File: packages/prisma/schema.prisma:891-891
Timestamp: 2025-07-18T08:47:01.264Z
Learning: The Outlook Calendar integration in Cal.com intentionally reuses subscription IDs across multiple event types for efficiency. The `upsertSelectedCalendarsForEventTypeIds` method creates separate SelectedCalendar records for each eventTypeId, all sharing the same outlookSubscriptionId. This subscription sharing pattern means that unique constraints like `@unique([outlookSubscriptionId, eventTypeId])` should not be applied as they would prevent this intended functionality.

Applied to files:

  • packages/prisma/schema.prisma
📚 Learning: 2025-08-19T09:47:49.478Z
Learnt from: eunjae-lee
PR: calcom/cal.com#23166
File: packages/prisma/migrations/20250818151914_routing_form_response_denormalized_backfill2/migration.sql:65-66
Timestamp: 2025-08-19T09:47:49.478Z
Learning: The Booking table has a unique constraint and index on the uid column (defined as `uid String unique` in schema.prisma), so JOINs on Booking.uid have optimal performance and don't require additional indexing.

Applied to files:

  • packages/prisma/schema.prisma
🪛 dotenv-linter (3.3.0)
.env.example

[warning] 150-150: [ExtraBlankLine] Extra blank line detected

(ExtraBlankLine)

⏰ Context from checks skipped due to timeout of 180000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Install dependencies / Yarn install & cache
🔇 Additional comments (12)
packages/features/calendar-subscription/lib/sync/CalendarSyncService.ts (1)

1-1: Verify import source for CalendarSubscriptionEventItem.

Confirm the type is exported from CalendarSubscriptionPort.interface (or re-exported). If it lives in CalendarSubscriptionFeatures.interface, update the import to match.

packages/features/flags/config.ts (2)

29-31: Add defaults and docs for new flags.

Found declarations/usage in:

  • packages/features/flags/config.ts
  • packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts (feature checks)
  • packages/features/calendar-subscription/lib/integration.test.ts

No seed entries or admin UI toggle / access-check registrations were found — add default seed values, register UI toggles (if applicable), and document each flag's purpose/expected behavior.


8-8: ```shell
#!/bin/bash
set -euo pipefail

echo "rg matches for 'calendar' or 'calendar-sync' (context lines):"
rg -n --hidden --no-ignore -S -C3 'calendar-sync|calendar' || true
echo
echo "=== packages/features/flags/config.ts ==="
nl -ba packages/features/flags/config.ts || true
echo
echo "=== packages/features/flags/hooks/index.ts ==="
nl -ba packages/features/flags/hooks/index.ts || true


</blockquote></details>
<details>
<summary>packages/features/calendar-subscription/lib/CalendarSubscriptionFeatures.interface.ts (1)</summary><blockquote>

`82-86`: **Align credential type for fetchEvents with subscribe/unsubscribe.**

subscribe/unsubscribe use CalendarCredential (includes email), but fetchEvents expects CredentialForCalendarService. If adapters need email for delta sync, make this consistent or document the divergence.



Option if consistency desired:

```diff
-  fetchEvents(
-    selectedCalendar: SelectedCalendar,
-    credential: CredentialForCalendarService
-  ): Promise<CalendarSubscriptionEvent>;
+  fetchEvents(
+    selectedCalendar: SelectedCalendar,
+    credential: CalendarCredential
+  ): Promise<CalendarSubscriptionEvent>;
packages/prisma/schema.prisma (2)

928-934: Schema vs service/test field naming mismatch.

Schema uses syncToken/syncedAt/syncErrorAt/syncErrorCount but tests/repository stubs use lastSyncToken/lastSyncedAt/lastSyncErrorAt/syncErrorCount. Align naming or map in the repository DTO.


965-965: Confirm change from @id to @unique on BookingTimeStatusDenormalized.

Ensure no code relies on @id semantics; Prisma still needs a unique field for views, but switching can affect findById helpers.

packages/features/calendar-subscription/lib/integration.test.ts (2)

186-196: Microsoft validation flow likely uses validationToken in query and requires plaintext echo.

Current test places token in body and expects JSON 200. Consider aligning with Graph’s handshake (token in query, 200 with token as text). Adjust tests after route fix.


82-99: SelectedCalendar stub fields don’t match schema.

Props like cacheEnabled, lastSyncToken, lastSyncedAt don’t exist in SelectedCalendar schema. Either extend repository DTO or update stubs.

Also applies to: 134-151, 198-215, 250-267, 312-329

apps/web/app/api/webhooks/calendar/[provider]/route.test.ts (1)

62-72: Confirm context.params shape

You’re passing params as a Promise. In Next.js App Router handlers, params is typically a plain object. Verify the route’s signature and adjust test scaffolding if necessary.

packages/prisma/migrations/20250915124730_add_calendar_sync_and_cache/migration.sql (1)

40-46: Well-designed indexes for query performance.

The indexes are well-chosen for the expected query patterns:

  • Composite index on (start, end, status) for time-range and status filtering
  • Index on (selectedCalendarId, iCalUID) for calendar-specific lookups
  • Unique constraint on (selectedCalendarId, externalId) prevents duplicate events
packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts (2)

1-11: Consider using a named export instead of default export pattern.

While this file uses a named export (which is good), ensure consistency across the codebase by avoiding default exports in other adapter files.


122-122: No change required — toISOString() matches Microsoft Graph's RFC3339 UTC requirement.

Microsoft Graph subscription expirationDateTime must be an RFC3339 UTC timestamp (include the trailing "Z"); Date.prototype.toISOString() produces that format, so this line is correct.

status: event.status,
isAllDay: !!event.start?.date && !event.start?.dateTime,
timeZone: event.start?.timeZone || null,
originalStartTime: event.originalStartTime?.dateTime,
Copy link
Contributor

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Handle undefined originalStartTime gracefully.

The code references event.originalStartTime?.dateTime but this field might be originalStartDate based on the event structure.


🏁 Script executed:

#!/bin/bash
# Check the Google Calendar API Event schema fields for originalStartTime
rg -A 3 -B 3 "originalStart" --type ts

Length of output: 7148


Fix originalStartDate mapping in GoogleCalendarSubscription.adapter.ts

Adapter currently sets originalStartTime: event.originalStartTime?.dateTime, but the internal model and cache use originalStartDate?: Date | null. Replace the line:

originalStartTime: event.originalStartTime?.dateTime,

with:

originalStartDate: event.originalStartTime ? new Date(event.originalStartTime.dateTime ?? event.originalStartTime.date) : null

Files to check: packages/features/calendar-subscription/adapters/GoogleCalendarSubscription.adapter.ts, packages/features/calendar-subscription/lib/CalendarSubscriptionFeatures.interface.ts, packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts.

🤖 Prompt for AI Agents
In
packages/features/calendar-subscription/adapters/GoogleCalendarSubscription.adapter.ts
around line 163, the adapter incorrectly maps originalStartTime to
event.originalStartTime?.dateTime while the internal model/cache expect
originalStartDate?: Date | null; replace that property mapping to set
originalStartDate to a Date when event.originalStartTime exists (using
event.originalStartTime.dateTime if present, otherwise
event.originalStartTime.date) and to null when it does not; update the adapter
to construct new Date(...) accordingly and then verify and update the
types/usages in
packages/features/calendar-subscription/lib/CalendarSubscriptionFeatures.interface.ts
and
packages/features/calendar-subscription/lib/cache/CalendarCacheEventService.ts
so they reference originalStartDate?: Date | null (not originalStartTime) and
handle null safely.

};
})
.filter((e) => !!e.id) // safely remove events with no ID
.filter((e) => e.start < now); // remove old events
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Fix filter logic for future events.

The comment says "remove old events" but the filter e.start < now actually removes future events, keeping only past events. This appears to be inverted logic.

       })
       .filter((e) => !!e.id) // safely remove events with no ID
-      .filter((e) => e.start < now); // remove old events
+      .filter((e) => e.start > now); // remove past events, keep future events

If the intention is actually to keep past events for caching historical data, then update the comment:

-      .filter((e) => e.start < now); // remove old events
+      .filter((e) => e.start < now); // keep only past events for historical cache
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.filter((e) => e.start < now); // remove old events
})
.filter((e) => !!e.id) // safely remove events with no ID
.filter((e) => e.start > now); // remove past events, keep future events
🤖 Prompt for AI Agents
In
packages/features/calendar-subscription/adapters/GoogleCalendarSubscription.adapter.ts
around line 169, the filter predicate and comment are inverted: the code
currently uses .filter((e) => e.start < now) but the comment says "remove old
events" while that predicate actually keeps only past events and removes future
ones; fix by changing the predicate to .filter((e) => e.start >= now) to keep
future events (thus removing old ones) and ensure the comment matches ("remove
old events / keep future events"); alternatively, if the intent was to keep past
events for caching, update the comment to reflect that instead of changing the
predicate.

Comment on lines +79 to +99
async validate(context: CalendarSubscriptionWebhookContext): Promise<boolean> {
// validate handshake
const validationToken = context?.query?.get("validationToken");
if (validationToken) return true;

// validate notifications
const clientState =
context?.headers?.get("clientState") ??
(typeof context?.body === "object" && context.body !== null && "clientState" in context.body
? (context.body as { clientState?: string }).clientState
: undefined);
if (!this.webhookToken) {
log.warn("MICROSOFT_WEBHOOK_TOKEN missing");
return false;
}
if (clientState !== this.webhookToken) {
log.warn("Invalid clientState", { clientState });
return false;
}
return true;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Security concern: Webhook validation timing attack vulnerability.

The string comparison clientState !== this.webhookToken on line 94 is vulnerable to timing attacks. Use a constant-time comparison function to prevent attackers from inferring the token through response timing analysis.

+import { timingSafeEqual } from "crypto";
+
 async validate(context: CalendarSubscriptionWebhookContext): Promise<boolean> {
   // validate handshake
   const validationToken = context?.query?.get("validationToken");
   if (validationToken) return true;

   // validate notifications
   const clientState =
     context?.headers?.get("clientState") ??
     (typeof context?.body === "object" && context.body !== null && "clientState" in context.body
       ? (context.body as { clientState?: string }).clientState
       : undefined);
   if (!this.webhookToken) {
     log.warn("MICROSOFT_WEBHOOK_TOKEN missing");
     return false;
   }
-  if (clientState !== this.webhookToken) {
+  // Use constant-time comparison to prevent timing attacks
+  const clientStateBuffer = Buffer.from(clientState || "");
+  const webhookTokenBuffer = Buffer.from(this.webhookToken);
+  if (clientStateBuffer.length !== webhookTokenBuffer.length ||
+      !timingSafeEqual(clientStateBuffer, webhookTokenBuffer)) {
     log.warn("Invalid clientState", { clientState });
     return false;
   }
   return true;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async validate(context: CalendarSubscriptionWebhookContext): Promise<boolean> {
// validate handshake
const validationToken = context?.query?.get("validationToken");
if (validationToken) return true;
// validate notifications
const clientState =
context?.headers?.get("clientState") ??
(typeof context?.body === "object" && context.body !== null && "clientState" in context.body
? (context.body as { clientState?: string }).clientState
: undefined);
if (!this.webhookToken) {
log.warn("MICROSOFT_WEBHOOK_TOKEN missing");
return false;
}
if (clientState !== this.webhookToken) {
log.warn("Invalid clientState", { clientState });
return false;
}
return true;
}
import { timingSafeEqual } from "crypto";
async validate(context: CalendarSubscriptionWebhookContext): Promise<boolean> {
// validate handshake
const validationToken = context?.query?.get("validationToken");
if (validationToken) return true;
// validate notifications
const clientState =
context?.headers?.get("clientState") ??
(typeof context?.body === "object" && context.body !== null && "clientState" in context.body
? (context.body as { clientState?: string }).clientState
: undefined);
if (!this.webhookToken) {
log.warn("MICROSOFT_WEBHOOK_TOKEN missing");
return false;
}
// Use constant-time comparison to prevent timing attacks
const clientStateBuffer = Buffer.from(clientState || "");
const webhookTokenBuffer = Buffer.from(this.webhookToken);
if (clientStateBuffer.length !== webhookTokenBuffer.length ||
!timingSafeEqual(clientStateBuffer, webhookTokenBuffer)) {
log.warn("Invalid clientState", { clientState });
return false;
}
return true;
}
🤖 Prompt for AI Agents
In
packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts
around lines 79 to 99, the current direct string comparison clientState !==
this.webhookToken is vulnerable to timing attacks; replace it with a
constant-time comparison: ensure both values are non-null, convert them to
Buffers (or Uint8Array) of equal length (or early-fail if lengths differ) and
use crypto.timingSafeEqual (or a vetted constant-time compare) to compare the
buffers, preserving the existing log behavior on mismatch and returning false on
invalid/missing values.

Comment on lines +161 to +178
if (deltaLink) {
const path = this.stripBase(deltaLink);
const r = await this.request<MicrosoftGraphEventsResponse>(client, "GET", path);
items.push(...r.value);
deltaLink = r["@odata.deltaLink"] ?? deltaLink;
} else {
let next: string | null = `/me/calendars/${selectedCalendar.externalId}/events/delta`;
while (next) {
const r: MicrosoftGraphEventsResponse = await this.request<MicrosoftGraphEventsResponse>(
client,
"GET",
next
);
items.push(...r.value);
deltaLink = r["@odata.deltaLink"] ?? deltaLink;
next = r["@odata.nextLink"] ? this.stripBase(r["@odata.nextLink"]) : null;
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Potential infinite loop risk in delta pagination.

The while loop on line 168 could potentially run indefinitely if the Graph API keeps returning nextLink values. Consider adding a maximum iteration limit to prevent resource exhaustion.

 } else {
   let next: string | null = `/me/calendars/${selectedCalendar.externalId}/events/delta`;
+  const MAX_ITERATIONS = 100;
+  let iterations = 0;
   while (next) {
+    if (++iterations > MAX_ITERATIONS) {
+      log.error("Max iterations reached while fetching delta events", { 
+        calendarId: selectedCalendar.externalId 
+      });
+      throw new Error("Too many pages in delta query");
+    }
     const r: MicrosoftGraphEventsResponse = await this.request<MicrosoftGraphEventsResponse>(
       client,
       "GET",
       next
     );
     items.push(...r.value);
     deltaLink = r["@odata.deltaLink"] ?? deltaLink;
     next = r["@odata.nextLink"] ? this.stripBase(r["@odata.nextLink"]) : null;
   }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (deltaLink) {
const path = this.stripBase(deltaLink);
const r = await this.request<MicrosoftGraphEventsResponse>(client, "GET", path);
items.push(...r.value);
deltaLink = r["@odata.deltaLink"] ?? deltaLink;
} else {
let next: string | null = `/me/calendars/${selectedCalendar.externalId}/events/delta`;
while (next) {
const r: MicrosoftGraphEventsResponse = await this.request<MicrosoftGraphEventsResponse>(
client,
"GET",
next
);
items.push(...r.value);
deltaLink = r["@odata.deltaLink"] ?? deltaLink;
next = r["@odata.nextLink"] ? this.stripBase(r["@odata.nextLink"]) : null;
}
}
if (deltaLink) {
const path = this.stripBase(deltaLink);
const r = await this.request<MicrosoftGraphEventsResponse>(client, "GET", path);
items.push(...r.value);
deltaLink = r["@odata.deltaLink"] ?? deltaLink;
} else {
let next: string | null = `/me/calendars/${selectedCalendar.externalId}/events/delta`;
const MAX_ITERATIONS = 100;
let iterations = 0;
while (next) {
if (++iterations > MAX_ITERATIONS) {
log.error("Max iterations reached while fetching delta events", {
calendarId: selectedCalendar.externalId
});
throw new Error("Too many pages in delta query");
}
const r: MicrosoftGraphEventsResponse = await this.request<MicrosoftGraphEventsResponse>(
client,
"GET",
next
);
items.push(...r.value);
deltaLink = r["@odata.deltaLink"] ?? deltaLink;
next = r["@odata.nextLink"] ? this.stripBase(r["@odata.nextLink"]) : null;
}
}
🤖 Prompt for AI Agents
In
packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts
around lines 161 to 178, the while loop that follows nextLink pagination can run
indefinitely if the Graph API continuously returns @odata.nextLink; add a
max-iteration safety guard (e.g., const MAX_DELTA_PAGES = 1000) and a counter
incremented each loop, break the loop (and log a warning or throw a controlled
error) when the counter exceeds the limit to prevent resource exhaustion, and
ensure you still set deltaLink and collect items before breaking so no data is
lost.

Comment on lines +187 to +210
private parseEvents(events: MicrosoftGraphEvent[]): CalendarSubscriptionEventItem[] {
return events
.map((e) => {
const busy = e.showAs === "busy" || e.showAs === "tentative" || e.showAs === "oof";
const start = e.start?.dateTime ? new Date(e.start.dateTime) : new Date();
const end = e.end?.dateTime ? new Date(e.end.dateTime) : new Date();

return {
id: e.id,
iCalUID: e.iCalUId ?? e.id,
start,
end,
busy,
summary: e.subject,
description: e.bodyPreview,
location: e.location?.displayName,
kind: e.type ?? "microsoftgraph#event",
status: e.isCancelled ? "cancelled" : "confirmed",
isAllDay: !!e.isAllDay,
timeZone: e.start?.timeZone ?? null,
};
})
.filter((i: CalendarSubscriptionEventItem) => Boolean(i.id));
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Handle missing date fields gracefully.

The parseEvents method defaults to new Date() when start/end dates are missing (lines 191-192), which could lead to incorrect event times. Consider filtering out events with missing dates or using a more appropriate default.

 private parseEvents(events: MicrosoftGraphEvent[]): CalendarSubscriptionEventItem[] {
   return events
-    .map((e) => {
+    .filter((e) => e.start?.dateTime && e.end?.dateTime) // Filter out events without dates
+    .map((e) => {
       const busy = e.showAs === "busy" || e.showAs === "tentative" || e.showAs === "oof";
-      const start = e.start?.dateTime ? new Date(e.start.dateTime) : new Date();
-      const end = e.end?.dateTime ? new Date(e.end.dateTime) : new Date();
+      const start = new Date(e.start!.dateTime);
+      const end = new Date(e.end!.dateTime);

       return {
         id: e.id,
         iCalUID: e.iCalUId ?? e.id,
         start,
         end,
         busy,
         summary: e.subject,
         description: e.bodyPreview,
         location: e.location?.displayName,
         kind: e.type ?? "microsoftgraph#event",
         status: e.isCancelled ? "cancelled" : "confirmed",
         isAllDay: !!e.isAllDay,
         timeZone: e.start?.timeZone ?? null,
       };
     })
     .filter((i: CalendarSubscriptionEventItem) => Boolean(i.id));
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private parseEvents(events: MicrosoftGraphEvent[]): CalendarSubscriptionEventItem[] {
return events
.map((e) => {
const busy = e.showAs === "busy" || e.showAs === "tentative" || e.showAs === "oof";
const start = e.start?.dateTime ? new Date(e.start.dateTime) : new Date();
const end = e.end?.dateTime ? new Date(e.end.dateTime) : new Date();
return {
id: e.id,
iCalUID: e.iCalUId ?? e.id,
start,
end,
busy,
summary: e.subject,
description: e.bodyPreview,
location: e.location?.displayName,
kind: e.type ?? "microsoftgraph#event",
status: e.isCancelled ? "cancelled" : "confirmed",
isAllDay: !!e.isAllDay,
timeZone: e.start?.timeZone ?? null,
};
})
.filter((i: CalendarSubscriptionEventItem) => Boolean(i.id));
}
private parseEvents(events: MicrosoftGraphEvent[]): CalendarSubscriptionEventItem[] {
return events
.filter((e) => e.start?.dateTime && e.end?.dateTime)
.map((e) => {
const busy = e.showAs === "busy" || e.showAs === "tentative" || e.showAs === "oof";
const start = new Date(e.start!.dateTime);
const end = new Date(e.end!.dateTime);
return {
id: e.id,
iCalUID: e.iCalUId ?? e.id,
start,
end,
busy,
summary: e.subject,
description: e.bodyPreview,
location: e.location?.displayName,
kind: e.type ?? "microsoftgraph#event",
status: e.isCancelled ? "cancelled" : "confirmed",
isAllDay: !!e.isAllDay,
timeZone: e.start?.timeZone ?? null,
};
})
.filter((i: CalendarSubscriptionEventItem) => Boolean(i.id));
}
🤖 Prompt for AI Agents
In
packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts
around lines 187 to 210, the current parseEvents implementation uses new Date()
as a fallback for missing start/end dateTime which produces incorrect event
times; instead, filter out events that lack valid start.dateTime or end.dateTime
before mapping (or skip them in the map), so only events with both start and end
dateTime are processed; ensure returned items always have real Date objects
(constructed from the dateTime strings) and adjust the final filter to remove
any items missing id or missing valid start/end.

Comment on lines +9 to +11
async upsertMany(events: CalendarCacheEvent[]): Promise<void> {
this.prismaClient.calendarCacheEvent.createMany({ data: events });
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Not actually upserting + missing await

  • createMany is not an upsert and will fail on conflicts or silently ignore updates.
  • The promise is not awaited; the method may return before persistence completes.

Apply a real upsert per unique key (assumes a composite unique on externalId + selectedCalendarId) and await:

-  async upsertMany(events: CalendarCacheEvent[]): Promise<void> {
-    this.prismaClient.calendarCacheEvent.createMany({ data: events });
-  }
+  async upsertMany(events: Partial<CalendarCacheEvent>[]): Promise<void> {
+    const items = events.filter(
+      (e): e is Partial<CalendarCacheEvent> & Pick<CalendarCacheEvent, "externalId" | "selectedCalendarId"> =>
+        !!e.externalId && !!e.selectedCalendarId
+    );
+    if (items.length === 0) return;
+    await this.prismaClient.$transaction(
+      items.map((e) =>
+        this.prismaClient.calendarCacheEvent.upsert({
+          where: {
+            externalId_selectedCalendarId: {
+              externalId: e.externalId,
+              selectedCalendarId: e.selectedCalendarId,
+            },
+          },
+          create: e as any,
+          update: e as any,
+        })
+      )
+    );
+  }

If a composite unique isn’t present, I can add one in the migration.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In
packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts
around lines 9 to 11, the method uses createMany (not an upsert) and does not
await the promise; change it to perform a true upsert per unique key (externalId
+ selectedCalendarId) by iterating over events and calling
prismaClient.calendarCacheEvent.upsert for each record (or use
prisma.$transaction with upserts for batch atomicity), include the appropriate
where clause matching the composite unique, provide update and create payloads,
await the operations (e.g., await Promise.all(...) or await
prisma.$transaction(...)), and if the composite unique constraint does not exist
add a migration to create it before relying on upsert.

Comment on lines +13 to +24
async deleteMany(events: Pick<CalendarCacheEvent, "externalId" | "selectedCalendarId">[]): Promise<void> {
const conditions = events.filter((c) => c.externalId && c.selectedCalendarId);
if (conditions.length === 0) {
return;
}

this.prismaClient.calendarCacheEvent.deleteMany({
where: {
OR: conditions,
},
});
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Await writes and guard empty input

deleteMany should be awaited; early-return on empty conditions is good.

-    this.prismaClient.calendarCacheEvent.deleteMany({
+    await this.prismaClient.calendarCacheEvent.deleteMany({
       where: {
         OR: conditions,
       },
     });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async deleteMany(events: Pick<CalendarCacheEvent, "externalId" | "selectedCalendarId">[]): Promise<void> {
const conditions = events.filter((c) => c.externalId && c.selectedCalendarId);
if (conditions.length === 0) {
return;
}
this.prismaClient.calendarCacheEvent.deleteMany({
where: {
OR: conditions,
},
});
}
async deleteMany(events: Pick<CalendarCacheEvent, "externalId" | "selectedCalendarId">[]): Promise<void> {
const conditions = events.filter((c) => c.externalId && c.selectedCalendarId);
if (conditions.length === 0) {
return;
}
await this.prismaClient.calendarCacheEvent.deleteMany({
where: {
OR: conditions,
},
});
}
🤖 Prompt for AI Agents
In
packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts
around lines 13 to 24, the call to
this.prismaClient.calendarCacheEvent.deleteMany is not awaited; keep the
existing early-return for empty conditions but await the deleteMany call so the
method actually waits for the DB write to complete (and propagate errors) before
returning.

Comment on lines +26 to +32
async deleteAllBySelectedCalendarId(selectedCalendarId: string): Promise<void> {
this.prismaClient.calendarCacheEvent.deleteMany({
where: {
selectedCalendarId,
},
});
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Await deleteAll

Missing await risks races in callers.

-    this.prismaClient.calendarCacheEvent.deleteMany({
+    await this.prismaClient.calendarCacheEvent.deleteMany({
       where: {
         selectedCalendarId,
       },
     });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async deleteAllBySelectedCalendarId(selectedCalendarId: string): Promise<void> {
this.prismaClient.calendarCacheEvent.deleteMany({
where: {
selectedCalendarId,
},
});
}
async deleteAllBySelectedCalendarId(selectedCalendarId: string): Promise<void> {
await this.prismaClient.calendarCacheEvent.deleteMany({
where: {
selectedCalendarId,
},
});
}
🤖 Prompt for AI Agents
In
packages/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.ts
around lines 26 to 32, the deleteAllBySelectedCalendarId method calls
prismaClient.calendarCacheEvent.deleteMany without awaiting it, which can cause
race conditions for callers; change the implementation to await the deleteMany
call (e.g., await this.prismaClient.calendarCacheEvent.deleteMany({...})) so the
async method only resolves after the deletion completes, preserving the
Promise<void> contract.

Comment on lines 86 to 100
/**
* Enriches a calendar with data from the cache
*
* @param selectedCalendarId calendar to enrich
* @returns
*/
async enrichCalendar({ selectedCalendarId }: { selectedCalendarId: string }): Promise<void> {
log.debug("enrichCalendar", { selectedCalendarId });

const selectedCalendar = await this.deps.selectedCalendarRepository.findById(selectedCalendarId);
if (!selectedCalendar) {
log.debug("Selected calendar not found", { selectedCalendarId });
return;
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Incomplete implementation of enrichCalendar method.

The enrichCalendar method fetches the calendar but doesn't perform any enrichment. This appears to be a stub implementation that should either be completed or marked with a TODO.

The method currently only fetches the calendar without performing enrichment. Would you like me to help implement the actual enrichment logic or add a TODO comment to track this?

Comment on lines 8 to 16
export type CalendarSubscriptionProvider = "google" | "microsoft";

export type CalendarSubscriptionWebhookContext = {
headers?: Headers;
query?: URLSearchParams;
body?: JsonValue;
};

export type CalendarSubscriptionResult = {
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

⚠️ Potential issue

Unify provider identifiers across the codebase.

Multiple files use different provider keys: this file ("google" | "microsoft"), factory ("google-calendar" | "office365-calendar"), tests ("google_calendar" | "office365_calendar"). This will cause runtime/compile-time breakage and adapter lookups to return undefined.

Standardize to one set (suggest: "google-calendar" | "office365-calendar") and export a single source of truth.

Apply:

- export type CalendarSubscriptionProvider = "google" | "microsoft";
+export const CALENDAR_PROVIDERS = ["google-calendar", "office365-calendar"] as const;
+export type CalendarSubscriptionProvider = (typeof CALENDAR_PROVIDERS)[number];

Also applies to: 42-46

🤖 Prompt for AI Agents
In
packages/features/calendar-subscription/lib/CalendarSubscriptionFeatures.interface.ts
around lines 8-16 and 42-46, the provider identifiers are inconsistent across
the codebase; change the union type to use the standardized identifiers
"google-calendar" | "office365-calendar" and export a single source of truth
(e.g., export a constant array of providers and a type derived from it or export
a named type alias) so all consumers import the same provider names; update any
exported types/consts in this file accordingly and ensure the file exports that
single provider constant/type for use by the factory and tests.

@calcom calcom deleted a comment from coderabbitai bot Sep 15, 2025
@calcom calcom deleted a comment from coderabbitai bot Sep 15, 2025
@calcom calcom deleted a comment from coderabbitai bot Sep 15, 2025
@calcom calcom deleted a comment from coderabbitai bot Sep 15, 2025
@calcom calcom deleted a comment from coderabbitai bot Sep 15, 2025
@calcom calcom deleted a comment from coderabbitai bot Sep 15, 2025
@calcom calcom deleted a comment from coderabbitai bot Sep 15, 2025
@calcom calcom deleted a comment from coderabbitai bot Sep 15, 2025
@calcom calcom deleted a comment from coderabbitai bot Sep 15, 2025
@calcom calcom deleted a comment from coderabbitai bot Sep 15, 2025
@calcom calcom deleted a comment from coderabbitai bot Sep 15, 2025
@calcom calcom deleted a comment from coderabbitai bot Sep 15, 2025
@calcom calcom deleted a comment from coderabbitai bot Sep 15, 2025
@calcom calcom deleted a comment from coderabbitai bot Sep 15, 2025
@calcom calcom deleted a comment from coderabbitai bot Sep 15, 2025
@calcom calcom deleted a comment from coderabbitai bot Sep 15, 2025
@calcom calcom deleted a comment from coderabbitai bot Sep 15, 2025
@calcom calcom deleted a comment from coderabbitai bot Sep 15, 2025
@volnei
Copy link
Contributor Author

volnei commented Sep 16, 2025

Closing this PR in favor of #23876 due to an error merging main

@volnei volnei closed this Sep 16, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core area: core, team members only ❗️ .env changes contains changes to env variables foundation ❗️ migrations contains migration files size/XXL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants