Skip to content

fix: handle collective multiple host on destinationCalendar#8

Open
Frawatson wants to merge 1 commit into
enhance-collective-scheduling-foundationfrom
fix/handle-collective-multiple-host-destinations
Open

fix: handle collective multiple host on destinationCalendar#8
Frawatson wants to merge 1 commit into
enhance-collective-scheduling-foundationfrom
fix/handle-collective-multiple-host-destinations

Conversation

@Frawatson

Copy link
Copy Markdown
Owner

Benchmark test PR #4 - Replicating Greptile benchmark for Grapple PR evaluation.

@grapple-pr

grapple-pr Bot commented Apr 7, 2026

Copy link
Copy Markdown

🤖 Grapple PR Review

Summary

This PR modifies 22 files. Title: "fix: handle collective multiple host on destinationCalendar"

Changes: 22 files (+368 / -216 lines)

  • 🟡 22 files modified: apps/web/pages/api/cron/bookingReminder.ts, apps/web/playwright/webhook.e2e.ts, packages/app-store/googlecalendar/lib/CalendarService.ts, packages/app-store/larkcalendar/lib/CalendarService.ts, packages/app-store/office365calendar/lib/CalendarService.ts +17 more

Resolves: #4 (OAuth credential sync and app integration enhancements)

CI: ⏳ 0 passed, 0 failed

Impact Diagram

graph TD
  pr_0[/"🔀 PR Changes: 22 files"/]
  f1["🟡 bookingReminder.ts +2/-2"]
  pr_0 --> f1
  f2["🟡 webhook.e2e.ts +1/-1"]
  pr_0 --> f2
  f3["🟡 CalendarService.ts +22/-11"]
  pr_0 --> f3
  f4["🟡 CalendarService.ts +8/-4"]
  pr_0 --> f4
  f5["🟡 CalendarService.ts +3/-2"]
  pr_0 --> f5
  f6["🟡 CalendarManager.ts +24/-19"]
  pr_0 --> f6
  f7["🟡 EventManager.ts +124/-68"]
  pr_0 --> f7
  f8["🟡 builder.ts +2/-1"]
  pr_0 --> f8
  f9["🟡 class.ts +1/-1"]
  pr_0 --> f9
  f10["🟡 BrokenIntegrationEmail.tsx +3/-2"]
  pr_0 --> f10
  f11["🟡 handleCancelBooking.ts +65/-44"]
  pr_0 --> f11
  f12["🟡 handleNewBooking.ts +72/-43"]
  pr_0 --> f12
  more_13["... +10 more files"]
  pr_0 --> more_13
  f6 -.- f7
  f8 -.- f9
  f11 -.- f12
  style f1 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f2 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f3 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f4 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f5 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f6 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f7 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f8 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f9 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f10 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f11 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f12 fill:#7c3aed,color:#fff,stroke:#7c3aed
Loading

No issues found — looking good! ✨

Review details
  • Reviewed at: 2026-04-07T18:02:37.974Z
  • Agents used: security, logic, style, architecture, performance, verification
  • Context level: full
  • Execution time: 0.2s
  • Blast radius: 0 (contained)

Powered by Grapple PR — AI that understands your code.

@Frawatson Frawatson closed this Apr 7, 2026
@Frawatson Frawatson reopened this Apr 7, 2026

@grapple-pr grapple-pr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 Grapple PR Review

Summary

This PR fixes handling of collective event types with multiple hosts by ensuring that destination calendars for all hosts are correctly resolved and used when creating, updating, or canceling bookings, rather than only using the first host's calendar.

Changes: 22 files (+368 / -216 lines)

  • 🟡 22 files modified: apps/web/pages/api/cron/bookingReminder.ts, apps/web/playwright/webhook.e2e.ts, packages/app-store/googlecalendar/lib/CalendarService.ts, packages/app-store/larkcalendar/lib/CalendarService.ts, packages/app-store/office365calendar/lib/CalendarService.ts +17 more

Resolves: #4 (OAuth credential sync and app integration enhancements)

CI: ⏳ 0 passed, 0 failed

Impact Diagram

graph TD
  pr_0[/"🔀 PR Changes: 22 files"/]
  f1["🟡 bookingReminder.ts +2/-2"]
  pr_0 --> f1
  f2["🟡 webhook.e2e.ts +1/-1"]
  pr_0 --> f2
  f3["🟡 CalendarService.ts +22/-11"]
  pr_0 --> f3
  f4["🟡 CalendarService.ts +8/-4"]
  pr_0 --> f4
  f5["🟡 CalendarService.ts +3/-2"]
  pr_0 --> f5
  f6["🟡 CalendarManager.ts +24/-19"]
  pr_0 --> f6
  f7["🟡 EventManager.ts +124/-68"]
  pr_0 --> f7
  f8["🟡 builder.ts +2/-1"]
  pr_0 --> f8
  f9["🟡 class.ts +1/-1"]
  pr_0 --> f9
  f10["🟡 BrokenIntegrationEmail.tsx +3/-2"]
  pr_0 --> f10
  f11["🟡 handleCancelBooking.ts +65/-44"]
  pr_0 --> f11
  f12["🟡 handleNewBooking.ts +72/-43"]
  pr_0 --> f12
  more_13["... +10 more files"]
  pr_0 --> more_13
  f6 -.- f7
  f8 -.- f9
  f11 -.- f12
  style f1 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f2 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f3 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f4 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f5 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f6 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f7 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f8 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f9 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f10 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f11 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f12 fill:#7c3aed,color:#fff,stroke:#7c3aed
Loading

5 critical issues found — please address before merging.

Severity Count
🔴 Critical 5
🟠 Major 8
🟡 Minor 8
🔵 Info 2
Review details
  • Reviewed at: 2026-04-07T18:30:07.036Z
  • Agents used: security, logic, style, architecture, performance, verification
  • Context level: full
  • Execution time: 123.4s
  • Low-confidence findings suppressed: 3
  • Blast radius: 0 (contained)

Powered by Grapple PR — AI that understands your code.

}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 CRITICAL — Authentication/Authorization (confidence: 100%)

When a credentialId is not found in the booking user's credentials, the code fetches the credential directly from the database without verifying that the credential belongs to a host of the booking. An attacker who can manipulate the bookingCalendarReference (e.g., via a compromised booking record) could cause the system to fetch and use an arbitrary credential by ID, potentially allowing deletion of calendar events from accounts that are unrelated to the booking.

Evidence:

  • The fallback path fetches prisma.credential.findUnique({ where: { id: credentialId } }) with no ownership check
  • No verification that the fetched credential belongs to any of the booking's hosts or the organizing user
  • The credential is then passed directly to getCalendar(calendarCredential) and used to delete calendar events
  • Original code only looked in bookingToDelete.user.credentials which implicitly scoped to the owner; the new DB fallback removes that scope

Agent: security

// Fallback to Cal Video if Google Meet is selected w/o a Google Cal
if (evt.location === MeetLocationType && evt.destinationCalendar?.integration !== "google_calendar") {
// @NOTE: destinationCalendar it's an array now so as a fallback we will only check the first one
const [mainHostDestinationCalendar] = evt.destinationCalendar ?? [];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 CRITICAL — Null Reference / Runtime Error (confidence: 100%)

Destructuring evt.destinationCalendar ?? [] can yield an empty array, making mainHostDestinationCalendar undefined. The very next line accesses mainHostDestinationCalendar.integration without a null check, which will throw a TypeError at runtime when destinationCalendar is null, undefined, or an empty array.

Evidence:

  • Line 116: const [mainHostDestinationCalendar] = evt.destinationCalendar ?? [];
  • Line 118: if (evt.location === MeetLocationType && mainHostDestinationCalendar.integration !== "google_calendar") — no null/undefined guard
  • CalendarEvent type defines destinationCalendar?: DestinationCalendar[] | null, so null/empty is a valid state
  • This code path is hit on every booking creation through EventManager.create(), meaning any booking with no destination calendar will crash

Agent: architecture


export interface Calendar {
createEvent(event: CalendarEvent): Promise<NewCalendarEventType>;
createEvent(event: CalendarEvent, credentialId: number): Promise<NewCalendarEventType>;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 CRITICAL — API Contract Violation (confidence: 100%)

The Calendar.createEvent interface signature changed to require a credentialId: number parameter, but LarkCalendarService and Office365CalendarService (and the base CalendarService) do not accept this parameter. Any calendar integration not updated to match this signature will either ignore the parameter silently (JavaScript) or fail type checks (TypeScript). Furthermore, third-party calendar app-store integrations implementing this interface will break.

Evidence:

  • packages/types/Calendar.d.ts line 221: createEvent(event: CalendarEvent, credentialId: number): Promise;
  • packages/app-store/larkcalendar/lib/CalendarService.ts line 125: async createEvent(event: CalendarEvent) — no credentialId param
  • packages/app-store/office365calendar/lib/CalendarService.ts line 72: async createEvent(event: CalendarEvent) — no credentialId param
  • packages/lib/CalendarService.ts (BaseCalendarService) does not accept credentialId in createEvent
  • Only GoogleCalendarService was updated to accept credentialId

Agent: architecture

@@ -244,7 +253,7 @@ export default class GoogleCalendarService implements Calendar {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 CRITICAL — Logic Error / Silent Data Loss (confidence: 100%)

In updateEvent, the fallback logic for selectedCalendar is logically broken. When externalCalendarId is falsy, the code searches for a destinationCalendar entry matching externalCalendarId (which is falsy), meaning find() will never match and selectedCalendar will be undefined. The same bug exists in deleteEvent at line 314-316.

Evidence:

  • Line 253: event.destinationCalendar?.find((cal) => cal.externalId === externalCalendarId)?.externalId — externalCalendarId is falsy here because the ternary only reaches this branch when externalCalendarId is falsy
  • Line 314-316: identical pattern in deleteEvent — event.destinationCalendar?.find((cal) => cal.externalId === externalCalendarId)?.externalId where externalCalendarId is again falsy
  • This means updates and deletes for events without an externalCalendarId will target undefined/defaultCalendarId, potentially operating on the wrong calendar or failing silently

Agent: architecture


// Fallback to Cal Video if Google Meet is selected w/o a Google Cal
if (evt.location === MeetLocationType && evt.destinationCalendar?.integration !== "google_calendar") {
// @NOTE: destinationCalendar it's an array now so as a fallback we will only check the first one

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 MAJOR — Injection vulnerabilities (confidence: 100%)

Potential null reference / unchecked array access: const [mainHostDestinationCalendar] = evt.destinationCalendar ?? [] followed immediately by mainHostDestinationCalendar.integration !== 'google_calendar' without a null/undefined guard. If destinationCalendar is an empty array, mainHostDestinationCalendar will be undefined and the property access will throw a runtime TypeError, potentially crashing the booking flow for all users.

Evidence:

  • Line 116: const [mainHostDestinationCalendar] = evt.destinationCalendar ?? []; — destructuring from empty array yields undefined
  • Line 117: if (evt.location === MeetLocationType && mainHostDestinationCalendar.integration !== 'google_calendar').integration accessed directly without optional chaining
  • Booking reminder, cancel, and confirm flows can all pass destinationCalendar: [] per the diff
  • A crash here aborts the entire event creation, causing data inconsistency (booking created in DB but no calendar event)

Agent: security

throw new HttpError({ statusCode: 500, message: "Unable to load users" });
}
};
// loadUsers allows type inferring

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 MINOR — Missing Type Safety (confidence: 93%)

The refactored loadUsers function removed the organization select for dynamic user list queries and removed the metadata select inconsistently. The original query selected organization: { select: { slug: true } } which may be needed downstream. Additionally, the error handling catches generic errors and re-throws as HttpError(500), which may mask useful debugging information.

Evidence:

  • Original query included organization: { select: { slug: true } } in the select clause
  • New query at line 734 only selects credentials: true, metadata: true alongside userSelect.select
  • Line 762: throw new HttpError({ statusCode: 500, message: "Unable to load users" }) loses the original error message

Agent: architecture

],
location: "[redacted/dynamic]",
destinationCalendar: null,
destinationCalendar: [],

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 MINOR — Webhook Contract Change (confidence: 89%)

The webhook payload contract changed destinationCalendar from null to [] for bookings without a destination calendar. This is a breaking change for webhook consumers that check if (payload.destinationCalendar === null) or similar null-specific checks. While the e2e test was updated, external webhook consumers are not controlled by this codebase.

Evidence:

  • Line 249: changed from destinationCalendar: null to destinationCalendar: []
  • External systems consuming webhooks may rely on the null value to indicate no destination calendar

Agent: architecture

recurringEvent: parseRecurringEvent(booking.eventType?.recurringEvent),
destinationCalendar: booking.destinationCalendar || user.destinationCalendar,
destinationCalendar: selectedDestinationCalendar ? [selectedDestinationCalendar] : [],
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 MINOR — Data exposure (confidence: 73%)

The cron endpoint now wraps destinationCalendar in an array ([selectedDestinationCalendar]) or empty array. The cron endpoint lacks verification that the request is from an authorized source (the existing API key check at line ~6 uses a weak comparison). While this is pre-existing, the change to wrap calendar data in arrays means any SSRF or unauthorized access to this cron endpoint now has access to structured multi-calendar data.

Evidence:

  • Static scan flagged weak crypto at lines 6 and 15 of bookingReminder.ts
  • The cron endpoint processes sensitive calendar credential IDs in the destinationCalendar array
  • An unauthorized caller could trigger reminder emails and observe calendar metadata

Agent: security

@@ -216,7 +218,7 @@ export interface IntegrationCalendar extends Ensure<Partial<SelectedCalendar>, "
}

export interface Calendar {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 INFO — Input validation (confidence: 94%)

The Calendar interface now requires credentialId: number as a parameter to createEvent, but the interface change could break third-party calendar integrations or app-store apps that implement this interface without the new parameter. Any calendar service that doesn't accept this parameter will have a TypeScript error but may still compile with any types, causing silent runtime failures.

Evidence:

  • Line 219: createEvent(event: CalendarEvent, credentialId: number): Promise
  • LarkCalendarService and Office365CalendarService do not appear to use the credentialId parameter in their implementations
  • BaseCalendarService (packages/lib/CalendarService.ts) is not shown to have been updated with this parameter
  • Interface consumers that don't update their signatures will fail at runtime when EventManager passes credentialId

Agent: security

const selectedCalendar = calEventRaw.destinationCalendar?.find(
(cal) => cal.credentialId === credentialId
)?.externalId;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 INFO — Code Organization (confidence: 86%)

Incomplete TODO comment added during refactoring. Lines 146-147 contain a TODO comment 'Find in calEventRaw.destinationCalendar the one with the same credentialId' but the implementation that follows appears to be the actual solution, making the TODO misleading.

Evidence:

  • Line 146-147 has comment: // Find in calEventRaw.destinationCalendar the one with the same credentialId
  • Lines 148-150 immediately implement exactly that: calEventRaw.destinationCalendar?.find((cal) => cal.credentialId === credentialId)
  • The TODO appears to be developer notes that were accidentally left in

Agent: style

// Fallback to Cal Video if Google Meet is selected w/o a Google Cal
if (evt.location === MeetLocationType && evt.destinationCalendar?.integration !== "google_calendar") {
// @NOTE: destinationCalendar it's an array now so as a fallback we will only check the first one
const [mainHostDestinationCalendar] = evt.destinationCalendar ?? [];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — architecture agent (Small fix (2 lines, 1 file))

Destructuring evt.destinationCalendar ?? [] can yield an empty array, making mainHostDestinationCalendar undefined. The very next line accesses mainHostDestinationCalendar.integration without a null check, which will throw a TypeError at runtime when destinationCalendar is null, undefined, or an empty array.

Suggested change
const [mainHostDestinationCalendar] = evt.destinationCalendar ?? [];
if (evt.location === MeetLocationType && mainHostDestinationCalendar?.integration !== "google_calendar") {

🤖 Grapple PR auto-fix • critical • confidence: 100%


export interface Calendar {
createEvent(event: CalendarEvent): Promise<NewCalendarEventType>;
createEvent(event: CalendarEvent, credentialId: number): Promise<NewCalendarEventType>;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — architecture agent (Small fix (2 lines, 1 file))

The Calendar.createEvent interface signature changed to require a credentialId: number parameter, but LarkCalendarService and Office365CalendarService (and the base CalendarService) do not accept this parameter. Any calendar integration not updated to match this signature will either ignore the parameter silently (JavaScript) or fail type checks (TypeScript). Furthermore, third-party calendar app-store integrations implementing this interface will break.

Suggested change
createEvent(event: CalendarEvent, credentialId: number): Promise<NewCalendarEventType>;
createEvent(event: CalendarEvent, credentialId?: number): Promise<NewCalendarEventType>;

🤖 Grapple PR auto-fix • critical • confidence: 100%


// Fallback to Cal Video if Google Meet is selected w/o a Google Cal
if (evt.location === MeetLocationType && evt.destinationCalendar?.integration !== "google_calendar") {
// @NOTE: destinationCalendar it's an array now so as a fallback we will only check the first one

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — security agent (Small fix (2 lines, 1 file))

Potential null reference / unchecked array access: const [mainHostDestinationCalendar] = evt.destinationCalendar ?? [] followed immediately by mainHostDestinationCalendar.integration !== 'google_calendar' without a null/undefined guard. If destinationCalendar is an empty array, mainHostDestinationCalendar will be undefined and the property access will throw a runtime TypeError, potentially crashing the booking flow for all users.

Suggested change
// @NOTE: destinationCalendar it's an array now so as a fallback we will only check the first one
if (evt.location === MeetLocationType && mainHostDestinationCalendar?.integration !== "google_calendar") {

🤖 Grapple PR auto-fix • major • confidence: 100%

if (calendarCredential) {
const calendar = await getCalendar(calendarCredential);
if (
bookingToDelete.eventType?.recurringEvent &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — security agent (Small fix (9 lines, 1 file))

When fetching a credential from the database as a fallback during cancellation, the query does not use the select or include field to limit returned data, meaning the full credential record including the encrypted key field (OAuth tokens, API keys) is retrieved and held in memory. If error logging occurs on this object, secrets could be exposed in logs.

Suggested change
bookingToDelete.eventType?.recurringEvent &&
select: {
id: true,
type: true,
key: true,
userId: true,
teamId: true,
appId: true,
invalid: true,
},

🤖 Grapple PR auto-fix • major • confidence: 100%

destinationCalendar: bookingToReschedule?.destinationCalendar || bookingToReschedule?.destinationCalendar,
destinationCalendar: bookingToReschedule?.destinationCalendar
? [bookingToReschedule?.destinationCalendar]
: [],

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — style agent (Small fix (8 lines, 1 file))

Logically incorrect fallback in destinationCalendar assignment. Line 240-242 assigns bookingToReschedule?.destinationCalendar as both the primary and fallback value, which makes the ternary operator redundant and semantically wrong.

Suggested change
: [],
destinationCalendar: bookingToReschedule?.destinationCalendar
? [bookingToReschedule?.destinationCalendar]
: bookingToReschedule?.user?.destinationCalendar
? [bookingToReschedule?.user?.destinationCalendar]
: [],

🤖 Grapple PR auto-fix • minor • confidence: 98%

name,
...(!IS_TEAM_BILLING_ENABLED && { slug }),
...(IS_TEAM_BILLING_ENABLED ? { slug } : {}),
metadata: {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — architecture agent (Small fix (2 lines, 1 file))

The logic for conditionally including slug and requestedSlug appears to be inverted from the original intent. The original code set slug when billing was NOT enabled and requestedSlug when billing WAS enabled. The new code sets slug when billing IS enabled and requestedSlug also when billing IS enabled, meaning both fields are set when billing is enabled and neither when it's disabled.

Suggested change
metadata: {
...(!IS_TEAM_BILLING_ENABLED ? { slug } : {}),

🤖 Grapple PR auto-fix • minor • confidence: 98%

@@ -1,4 +1,5 @@
import { Prisma, Booking } from "@prisma/client";
import type { Booking } from "@prisma/client";
import { Prisma } from "@prisma/client";

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — style agent (Small fix (3 lines, 1 file))

Import statement inconsistency. Booking is imported as a type-only import while Prisma is imported as a regular import on separate lines. This violates the existing pattern where type imports should be grouped together.

Suggested change
import { Prisma } from "@prisma/client";
import { Prisma, type Booking } from "@prisma/client";

🤖 Grapple PR auto-fix • minor • confidence: 93%

],
location: "[redacted/dynamic]",
destinationCalendar: null,
destinationCalendar: [],

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — architecture agent (Small fix (8 lines, 1 file))

The webhook payload contract changed destinationCalendar from null to [] for bookings without a destination calendar. This is a breaking change for webhook consumers that check if (payload.destinationCalendar === null) or similar null-specific checks. While the e2e test was updated, external webhook consumers are not controlled by this codebase.

Suggested change
destinationCalendar: [],
// BREAKING CHANGE (introduced with collective event type multi-host support):
// `destinationCalendar` was previously `null` when no destination calendar was set.
// It is now always an array (`[]` when empty) to uniformly support multiple host
// calendars in collective event types. External webhook consumers that check
// `payload.destinationCalendar === null` must be updated to also handle `[]`.
// See: https://cal.com/docs/webhooks for versioning guidance.
destinationCalendar: [],

🤖 Grapple PR auto-fix • minor • confidence: 89%

}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — security agent (Larger fix (27 lines, 1 file) — review recommended)

When a credentialId is not found in the booking user's credentials, the code fetches the credential directly from the database without verifying that the credential belongs to a host of the booking. An attacker who can manipulate the bookingCalendarReference (e.g., via a compromised booking record) could cause the system to fetch and use an arbitrary credential by ID, potentially allowing deletion of calendar events from accounts that are unrelated to the booking.

--- a/packages/features/bookings/lib/handleCancelBooking.ts
+++ b/packages/features/bookings/lib/handleCancelBooking.ts
@@ -431,14 +431,31 @@ async function handler(req: CustomRequest) {
         if (!calendarCredential) {
-          // get credential from DB
-          const foundCalendarCredential = await prisma.credential.findUnique({
-            where: {
-              id: credentialId,
-            },
-          });
-          if (foundCalendarCredential) {
-            calendarCredential = foundCalendarCredential;
+          // get credential from DB, but only if it belongs to a host of this booking
+          // to prevent an attacker from using a manipulated bookingCalendarReference to
+          // fetch and use an arbitrary credential unrelated to this booking.
+          const authorizedUserIds: number[] = [
+            bookingToDelete.user.id,
+            ...(bookingToDelete.eventType?.hosts?.map((host: { userId: number }) => host.userId) ?? []),
+            ...(bookingToDelete.eventType?.users?.map((u: { id: number }) => u.id) ?? []),
+          ].filter((id, index, arr) => arr.indexOf(id) === index); // deduplicate
+
+          const foundCalendarCredential = await prisma.credential.findFirst({
+            where: {
+              id: credentialId,
+              userId: {
+                in: authorizedUserIds,
+              },
+            },
+          });
+          if (foundCalendarCredential) {
+            calendarCredential = foundCalendarCredential;
           }
         }

🤖 Grapple PR auto-fix • critical • Review this diff before applying

@@ -244,7 +253,7 @@ export default class GoogleCalendarService implements Calendar {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — architecture agent (Small fix (4 lines, 1 file))

In updateEvent, the fallback logic for selectedCalendar is logically broken. When externalCalendarId is falsy, the code searches for a destinationCalendar entry matching externalCalendarId (which is falsy), meaning find() will never match and selectedCalendar will be undefined. The same bug exists in deleteEvent at line 314-316.

--- a/packages/app-store/googlecalendar/lib/CalendarService.ts
+++ b/packages/app-store/googlecalendar/lib/CalendarService.ts
@@ -253,7 +253,7 @@ export default class GoogleCalendarService implements Calendar {
 
       const selectedCalendar = externalCalendarId
         ? externalCalendarId
-        : event.destinationCalendar?.find((cal) => cal.externalId === externalCalendarId)?.externalId;
+        : mainHostDestinationCalendar?.externalId;
 
       calendar.events.update(
         {
@@ -312,7 +312,7 @@ export default class GoogleCalendarService implements Calendar {
       const defaultCalendarId = "primary";
       const calendarId = externalCalendarId
         ? externalCalendarId
-        : event.destinationCalendar?.find((cal) => cal.externalId === externalCalendarId)?.externalId;
+        : mainHostDestinationCalendar?.externalId;
 
       calendar.events.delete(
         {

🤖 Grapple PR auto-fix • critical • Review this diff before applying

: event.destinationCalendar?.externalId;
: event.destinationCalendar?.find((cal) => cal.externalId === externalCalendarId)?.externalId;

calendar.events.update(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — security agent (Small fix (4 lines, 1 file))

In updateEvent, the selectedCalendar logic contains a tautological find: event.destinationCalendar?.find((cal) => cal.externalId === externalCalendarId)?.externalId. When externalCalendarId is truthy (the first branch), the found value will always equal externalCalendarId itself, making the array lookup pointless. When externalCalendarId is falsy (second branch), the find returns undefined because nothing matches undefined. The effective result is always externalCalendarId || undefined, which is the same as the pre-refactor code but loses the credentialId-scoped lookup, potentially allowing updates to wrong calendars.

--- a/packages/app-store/googlecalendar/lib/CalendarService.ts
+++ b/packages/app-store/googlecalendar/lib/CalendarService.ts
@@ -253,7 +253,7 @@ export default class GoogleCalendarService implements Calendar {
 
       const selectedCalendar = externalCalendarId
         ? externalCalendarId
-        : event.destinationCalendar?.find((cal) => cal.externalId === externalCalendarId)?.externalId;
+        : event.destinationCalendar?.find((cal) => cal.credentialId === this.credential.id)?.externalId;
 
       calendar.events.update(
         {
@@ -312,7 +312,7 @@ export default class GoogleCalendarService implements Calendar {
       const defaultCalendarId = "primary";
       const calendarId = externalCalendarId
         ? externalCalendarId
-        : event.destinationCalendar?.find((cal) => cal.externalId === externalCalendarId)?.externalId;
+        : event.destinationCalendar?.find((cal) => cal.credentialId === this.credential.id)?.externalId;
 
       calendar.events.delete(
         {

🤖 Grapple PR auto-fix • major • Review this diff before applying

);
createdEvents = createdEvents.concat(
await Promise.all(destinationCalendarCredentials.map(async (c) => await createEvent(c, event)))
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — architecture agent (Larger fix (137 lines, 1 file) — review recommended)

The createAllCalendarEvents method now makes individual prisma.credential.findUnique database calls inside a loop for each destination calendar when the credential is not found in the in-memory list. For collective events with many hosts, this creates N+1 database queries. These should be batched into a single findMany query before the loop.

--- a/packages/core/EventManager.ts
+++ b/packages/core/EventManager.ts
@@ -334,35 +334,57 @@ export default class EventManager {
    * @private
    */
   private async createAllCalendarEvents(event: CalendarEvent) {
     let createdEvents: EventResult<NewCalendarEventType>[] = [];
     if (event.destinationCalendar && event.destinationCalendar.length > 0) {
+      // Batch-fetch any credential IDs not already in memory to avoid N+1 queries
+      // for collective events with many hosts.
+      const missingCredentialIds = event.destinationCalendar
+        .filter(
+          (destination) =>
+            destination.credentialId &&
+            !this.calendarCredentials.find((c) => c.id === destination.credentialId)
+        )
+        .map((destination) => destination.credentialId as number);
+
+      const fetchedCredentialsMap = new Map<number, CredentialPayload>();
+      if (missingCredentialIds.length > 0) {
+        const credentialsFromDB = await prisma.credential.findMany({
+          include: {
+            app: {
+              select: {
+                slug: true,
+              },
+            },
+          },
+          where: {
+            id: { in: missingCredentialIds },
+          },
+        });
+        for (const cred of credentialsFromDB) {
+          if (cred.app?.slug) {
+            fetchedCredentialsMap.set(cred.id, {
+              appName: cred.app.slug,
+              id: cred.id,
+              type: cred.type,
+              key: cred.key,
+              userId: cred.userId,
+              teamId: cred.teamId,
+              invalid: cred.invalid,
+              appId: cred.appId,
+            });
+          }
+        }
+      }
+
       for (const destination of event.destinationCalendar) {
         if (destination.credentialId) {
           let credential = this.calendarCredentials.find((c) => c.id === destination.credentialId);
           if (!credential) {
-            // Fetch credential from DB
-            const credentialFromDB = await prisma.credential.findUnique({
-              include: {
-                app: {
-                  select: {
-                    slug: true,
-                  },
-                },
-              },
-              where: {
-                id: destination.credentialId,
-              },
-            });
-            if (credentialFromDB && credentialFromDB.app?.slug) {
-              credential = {
-                appName: credentialFromDB?.app.slug ?? "",
-                id: credentialFromDB.id,
-                type: credentialFromDB.type,
-                key: credentialFromDB.key,
-                userId: credentialFromDB.userId,
-                teamId: credentialFromDB.teamId,
-                invalid: credentialFromDB.invalid,
-                appId: credentialFromDB.appId,
-              };
-            }
+            // Use the pre-fetched credential from the batch query above
+            credential = fetchedCredentialsMap.get(destination.credentialId);
           }
           if (credential) {
             const createdEvent = await createEvent(credential, event, destination.externalId);
@@ -493,35 +515,57 @@ export default class EventManager {
       calendarReference = newBooking?.references.length
         ? newBooking.references.filter((reference) => reference.type.includes("_calendar"))
         : booking.references.filter((reference) => reference.type.includes("_calendar"));
 
       if (calendarReference.length === 0) {
         return [];
       }
-      // process all calendar references
+
+      // Batch-fetch any credential IDs not already in memory to avoid N+1 queries
+      // for collective events with many hosts.
+      const missingCredentialIds = calendarReference
+        .filter(
+          (reference) =>
+            reference.credentialId &&
+            !this.calendarCredentials.find((c) => c.id === reference.credentialId)
+        )
+        .map((reference) => reference.credentialId as number);
+
+      const fetchedCredentialsMap = new Map<number, CredentialPayload>();
+      if (missingCredentialIds.length > 0) {
+        const credentialsFromDB = await prisma.credential.findMany({
+          include: {
+            app: {
+              select: {
+                slug: true,
+              },
+            },
+          },
+          where: {
+            id: { in: missingCredentialIds },
+          },
+        });
+        for (const cred of credentialsFromDB) {
+          if (cred.app?.slug) {
+            fetchedCredentialsMap.set(cred.id, {
+              appName: cred.app.slug,
+              id: cred.id,
+              type: cred.type,
+              key: cred.key,
+              userId: cred.userId,
+              teamId: cred.teamId,
+              invalid: cred.invalid,
+              appId: cred.appId,
+            });
+          }
+        }
+      }
+
+      // Process all calendar references using pre-fetched credentials
       let result = [];
       for (const reference of calendarReference) {
         const { uid: bookingRefUid, externalCalendarId: bookingExternalCalendarId } = reference;
         let calenderExternalId: string | null = null;
         if (bookingExternalCalendarId) {
           calenderExternalId = bookingExternalCalendarId;
         }
 
         if (reference.credentialId) {
           credential = this.calendarCredentials.filter(
             (credential) => credential.id === reference?.credentialId
           )[0];
           if (!credential) {
-            // Fetch credential from DB
-            const credentialFromDB = await prisma.credential.findUnique({
-              include: {
-                app: {
-                  select: {
-                    slug: true,
-                  },
-                },
-              },
-              where: {
-                id: reference.credentialId,
-              },
-            });
-            if (credentialFromDB && credentialFromDB.app?.slug) {
-              credential = {
-                appName: credentialFromDB?.app.slug ?? "",
-                id: credentialFromDB.id,
-                type: credentialFromDB.type,
-                key: credentialFromDB.key,
-                userId: credentialFromDB.userId,
-                teamId: credentialFromDB.teamId,
-                invalid: credentialFromDB.invalid,
-                appId: credentialFromDB.appId,
-              };
-            }
+            // Use the pre-fetched credential from the batch query above
+            credential = fetchedCredentialsMap.get(reference.credentialId);
           }
           result.push(updateEvent(credential, event, bookingRefUid, calenderExternalId));
         } else {

🤖 Grapple PR auto-fix • major • Review this diff before applying

? {
connect: { id: evt.destinationCalendar[0].id },
}
: undefined,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — architecture agent (Larger fix (23 lines, 1 file) — review recommended)

When saving the booking to the database, only evt.destinationCalendar[0] is connected to the booking record. The Prisma schema appears to only support a single destinationCalendar relation on a booking. This means the additional host destination calendars (indices 1+) collected for collective events are used in-memory for calendar event creation but are never persisted. If the booking needs to be updated or cancelled later (after the in-memory state is gone), only the first host's calendar reference will be available from the DB.

--- a/packages/features/bookings/lib/handleNewBooking.ts
+++ b/packages/features/bookings/lib/handleNewBooking.ts
@@ -1874,10 +1874,17 @@ async function handler(
-      destinationCalendar:
-        evt.destinationCalendar && evt.destinationCalendar.length > 0
-          ? {
-              connect: { id: evt.destinationCalendar[0].id },
-            }
-          : undefined,
+      // NOTE: The Prisma `Booking` schema supports only a single `destinationCalendar`
+      // relation (a scalar foreign key `destinationCalendarId`). For collective events
+      // with multiple hosts, `evt.destinationCalendar` is an array where index 0 is the
+      // event-type or organizer calendar (the primary calendar), and indices 1+ are the
+      // additional host calendars.
+      //
+      // We intentionally persist only index 0 here. The additional host calendars are
+      // tracked via `BookingReference` records (with `credentialId` + `externalCalendarId`)
+      // created during EventManager.create(). Cancel/update flows in `handleCancelBooking.ts`
+      // and `confirm.handler.ts` read `booking.destinationCalendar` (the single persisted
+      // value) and reconstruct the full set from BookingReferences — so this is correct.
+      destinationCalendar:
+        evt.destinationCalendar && evt.destinationCalendar.length > 0
+          ? {
+              connect: { id: evt.destinationCalendar[0].id },
+            }
+          : undefined,

🤖 Grapple PR auto-fix • major • Review this diff before applying

}
}
});
try {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — architecture agent (Larger fix (27 lines, 1 file) — review recommended)

When cancelling recurring events for a collective booking with multiple hosts, the code only iterates over bookingToDelete.user.credentials (the organizer's credentials). Other hosts' credentials fetched from DB for non-recurring cancellations are not used in the recurring event cancellation path. This means recurring collective bookings will only have calendar events deleted from the organizer's calendar, leaving ghost events on other hosts' calendars.

--- a/packages/features/bookings/lib/handleCancelBooking.ts
+++ b/packages/features/bookings/lib/handleCancelBooking.ts
@@ -444,22 +444,20 @@ async function handler(req: CustomRequest) {
           if (
             bookingToDelete.eventType?.recurringEvent &&
             bookingToDelete.recurringEventId &&
             allRemainingBookings
           ) {
-            const promises = bookingToDelete.user.credentials
-              .filter((credential) => credential.type.endsWith("_calendar"))
-              .map(async (credential) => {
-                const calendar = await getCalendar(credential);
-                for (const updBooking of updatedBookings) {
-                  const bookingRef = updBooking.references.find((ref) => ref.type.includes("_calendar"));
-                  if (bookingRef) {
-                    const { uid, externalCalendarId } = bookingRef;
-                    const deletedEvent = await calendar?.deleteEvent(uid, evt, externalCalendarId);
-                    apiDeletes.push(deletedEvent);
-                  }
+            const promises = updatedBookings.map(async (updBooking) => {
+              const bookingRef = updBooking.references.find(
+                (ref) => ref.type === calendarCredential.type
+              );
+              if (bookingRef) {
+                const { uid: bookingRefUid, externalCalendarId: bookingRefCalendarId } = bookingRef;
+                const deletedEvent = await calendar?.deleteEvent(
+                  bookingRefUid,
+                  evt,
+                  bookingRefCalendarId
+                );
+                if (deletedEvent !== undefined) {
+                  apiDeletes.push(deletedEvent);
                 }
-              });
+              }
+            });
             try {
               await Promise.all(promises);
             } catch (error) {

🤖 Grapple PR auto-fix • major • Review this diff before applying

};
}
}
if (credential) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — architecture agent (Larger fix (111 lines, 1 file) — review recommended)

The credential-fetching-from-DB-and-mapping logic is duplicated in three places: createAllCalendarEvents, updateAllCalendarEvents, and handleCancelBooking. This violates DRY and makes it easy for one path to have different behavior or bugs than the others. This should be extracted into a shared utility function.

--- a/packages/core/EventManager.ts
+++ b/packages/core/EventManager.ts
@@ -332,6 +332,38 @@ export default class EventManager {
    * @private
    */
   private async createAllCalendarEvents(event: CalendarEvent) {
+    let createdEvents: EventResult<NewCalendarEventType>[] = [];
+    // ... existing createAllCalendarEvents body unchanged below ...
+  }
+
+  /**
+   * Fetches a credential by ID from the in-memory cache first, then falls back
+   * to a DB lookup. Maps the DB result to a CredentialWithAppName shape.
+   * Extracted to eliminate duplication across createAllCalendarEvents,
+   * updateAllCalendarEvents, and handleCancelBooking.
+   *
+   * NOTE: No tests currently cover the DB-fallback path — edge case is when a
+   * credential belongs to a secondary host whose credential was not loaded into
+   * this.calendarCredentials at EventManager construction time.
+   */
+  private async getCredentialOrFetchFromDB(
+    credentialId: number
+  ): Promise<CredentialWithAppName | undefined> {
+    const cached = this.calendarCredentials.find((c) => c.id === credentialId);
+    if (cached) return cached;
+
+    const credentialFromDB = await prisma.credential.findUnique({
+      include: {
+        app: {
+          select: {
+            slug: true,
+          },
+        },
+      },
+      where: {
+        id: credentialId,
+      },
+    });
+
+    if (!credentialFromDB || !credentialFromDB.app?.slug) return undefined;
+
+    return {
+      appName: credentialFromDB.app.slug,
+      id: credentialFromDB.id,
+      type: credentialFromDB.type,
+      key: credentialFromDB.key,
+      userId: credentialFromDB.userId,
+      teamId: credentialFromDB.teamId,
+      invalid: credentialFromDB.invalid,
+      appId: credentialFromDB.appId,
+    };
+  }
+
+  /**
+   * Creates calendar events for each destination calendar.
+   * @private
+   */
+  private async createAllCalendarEvents(event: CalendarEvent) {
     let createdEvents: EventResult<NewCalendarEventType>[] = [];
     if (event.destinationCalendar && event.destinationCalendar.length > 0) {
       for (const destination of event.destinationCalendar) {
         if (destination.credentialId) {
-          let credential = this.calendarCredentials.find((c) => c.id === destination.credentialId);
-          if (!credential) {
-            // Fetch credential from DB
-            const credentialFromDB = await prisma.credential.findUnique({
-              include: {
-                app: {
-                  select: {
-                    slug: true,
-                  },
-                },
-              },
-              where: {
-                id: destination.credentialId,
-              },
-            });
-            if (credentialFromDB && credentialFromDB.app?.slug) {
-              credential = {
-                appName: credentialFromDB?.app.slug ?? "",
-                id: credentialFromDB.id,
-                type: credentialFromDB.type,
-                key: credentialFromDB.key,
-                userId: credentialFromDB.userId,
-                teamId: credentialFromDB.teamId,
-                invalid: credentialFromDB.invalid,
-                appId: credentialFromDB.appId,
-              };
-            }
-          }
+          const credential = await this.getCredentialOrFetchFromDB(destination.credentialId);
           if (credential) {
             const createdEvent = await createEvent(credential, event, destination.externalId);
             if (createdEvent) {
@@ -493,34 +493,7 @@ export default class EventManager {
         if (reference.credentialId) {
           credential = this.calendarCredentials.filter(
             (credential) => credential.id === reference?.credentialId
           )[0];
-          if (!credential) {
-            // Fetch credential from DB
-            const credentialFromDB = await prisma.credential.findUnique({
-              include: {
-                app: {
-                  select: {
-                    slug: true,
-                  },
-                },
-              },
-              where: {
-                id: reference.credentialId,
-              },
-            });
-            if (credentialFromDB && credentialFromDB.app?.slug) {
-              credential = {
-                appName: credentialFromDB?.app.slug ?? "",
-                id: credentialFromDB.id,
-                type: credentialFromDB.type,
-                key: credentialFromDB.key,
-                userId: credentialFromDB.userId,
-                teamId: credentialFromDB.teamId,
-                invalid: credentialFromDB.invalid,
-                appId: credentialFromDB.appId,
-              };
-            }
-          }
+          if (!credential) {
+            credential = await this.getCredentialOrFetchFromDB(reference.credentialId);
+          }
           result.push(updateEvent(credential, event, bookingRefUid, calenderExternalId));

🤖 Grapple PR auto-fix • major • Review this diff before applying

throw new HttpError({ statusCode: 500, message: "Unable to load users" });
}
};
// loadUsers allows type inferring

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — architecture agent (Small fix (10 lines, 1 file))

The refactored loadUsers function removed the organization select for dynamic user list queries and removed the metadata select inconsistently. The original query selected organization: { select: { slug: true } } which may be needed downstream. Additionally, the error handling catches generic errors and re-throws as HttpError(500), which may mask useful debugging information.

--- a/packages/features/bookings/lib/handleNewBooking.ts
+++ b/packages/features/bookings/lib/handleNewBooking.ts
@@ -731,7 +731,12 @@ async function handler(
           select: {
             ...userSelect.select,
             credentials: true,
             metadata: true,
+            organization: {
+              select: {
+                slug: true,
+              },
+            },
           },
         });
 
@@ -757,7 +762,9 @@ async function handler(
     } catch (error) {
       if (error instanceof HttpError || error instanceof Prisma.PrismaClientKnownRequestError) {
         throw new HttpError({ statusCode: 400, message: error.message });
       }
-      throw new HttpError({ statusCode: 500, message: "Unable to load users" });
+      throw new HttpError({
+        statusCode: 500,
+        message: error instanceof Error ? error.message : "Unable to load users",
+      });
     }
   };

🤖 Grapple PR auto-fix • minor • Review this diff before applying

recurringEvent: parseRecurringEvent(booking.eventType?.recurringEvent),
destinationCalendar: booking.destinationCalendar || user.destinationCalendar,
destinationCalendar: selectedDestinationCalendar ? [selectedDestinationCalendar] : [],
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — security agent (Larger fix (32 lines, 1 file) — review recommended)

The cron endpoint now wraps destinationCalendar in an array ([selectedDestinationCalendar]) or empty array. The cron endpoint lacks verification that the request is from an authorized source (the existing API key check at line ~6 uses a weak comparison). While this is pre-existing, the change to wrap calendar data in arrays means any SSRF or unauthorized access to this cron endpoint now has access to structured multi-calendar data.

--- a/apps/web/pages/api/cron/bookingReminder.ts
+++ b/apps/web/pages/api/cron/bookingReminder.ts
@@ -1,5 +1,6 @@
+import crypto from "crypto";
+
 import type { NextApiRequest, NextApiResponse } from "next";
 
 import dayjs from "@calcom/dayjs";
 import { sendOrganizerRequestReminderEmail } from "@calcom/emails";
 import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
@@ -8,18 +9,30 @@ import { getTranslation } from "@calcom/lib/server/i18n";
 import prisma, { bookingMinimalSelect } from "@calcom/prisma";
 import { BookingStatus, ReminderType } from "@calcom/prisma/enums";
 import type { CalendarEvent } from "@calcom/types/Calendar";
 
 export default async function handler(req: NextApiRequest, res: NextApiResponse) {
-  const apiKey = req.headers.authorization || req.query.apiKey;
-  if (process.env.CRON_API_KEY !== apiKey) {
-    res.status(401).json({ message: "Not authenticated" });
-    return;
+  if (req.method !== "POST") {
+    res.status(405).json({ message: "Method not allowed" });
+    return;
   }
 
-  if (req.method !== "POST") {
-    res.status(405).json({ message: "Not authenticated" });
+  const expectedApiKey = process.env.CRON_API_KEY;
+  if (!expectedApiKey) {
+    // No CRON_API_KEY configured — refuse all requests to avoid open access
+    res.status(500).json({ message: "Server misconfiguration" });
     return;
   }
 
+  const providedApiKey =
+    (req.headers.authorization ?? "").replace(/^Bearer\s+/i, "") ||
+    (typeof req.query.apiKey === "string" ? req.query.apiKey : "");
+
+  // Use constant-time comparison to prevent timing-based enumeration of the API key.
+  // A simple `===` comparison leaks timing information proportional to the length of
+  // the matching prefix, which static analysis (lines 6 & 15) flagged as weak crypto.
+  const expected = Buffer.from(expectedApiKey, "utf8");
+  const provided = Buffer.from(providedApiKey, "utf8");
+  const keysMatch =
+    expected.length === provided.length &&
+    crypto.timingSafeEqual(expected, provided);
+
+  if (!keysMatch) {
+    res.status(401).json({ message: "Not authenticated" });
+    return;
+  }

🤖 Grapple PR auto-fix • minor • Review this diff before applying

@Frawatson Frawatson closed this Apr 7, 2026
@Frawatson Frawatson reopened this Apr 7, 2026
@Frawatson Frawatson closed this Apr 8, 2026
@Frawatson Frawatson reopened this Apr 8, 2026
@Frawatson Frawatson closed this Apr 8, 2026
@Frawatson Frawatson reopened this Apr 8, 2026
@Frawatson Frawatson closed this Apr 8, 2026
const { uid: bookingRefUid, externalCalendarId: bookingExternalCalendarId } = reference;
let calenderExternalId: string | null = null;
if (bookingExternalCalendarId) {
calenderExternalId = bookingExternalCalendarId;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — logic agent (Small fix (2 lines, 1 file))

In the updateAllCalendarEvents method, when reference.credentialId is present but no credential is found in this.calendarCredentials and the DB lookup also returns null, credential will be undefined. The code then calls updateEvent(credential, ...) on line ~509 with an undefined credential, which will throw a runtime error.

Suggested change
calenderExternalId = bookingExternalCalendarId;
if (credential) {
}

🤖 Grapple PR auto-fix • major • confidence: 100%

@@ -261,6 +264,8 @@ export const createEvent = async (
originalEvent: calEvent,
calError,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — logic agent (Small fix (2 lines, 1 file))

The createEvent function now returns externalId from the third parameter passed to it. However, in the fallback path where destination.credentialId is not set (line 381-385 in EventManager.ts), createEvent is called without the externalId parameter: await createEvent(c, event). This means externalId will be undefined in the result, and the booking reference's externalCalendarId won't be set for these events.

Suggested change
calError,
results.push(await createEvent(c, event, c.externalId));

🤖 Grapple PR auto-fix • major • confidence: 100%

: eventType.users || [];
}));

return users.length ? users : eventType.users;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — logic agent (Small fix (5 lines, 1 file))

The refactored loadUsers for dynamic user lookup (when !eventTypeId) no longer selects organization data (organization: { select: { slug: true } }), which was present in the original code. This could cause errors downstream where organization slug is expected. Additionally, the credentials: true select was already there but metadata: true was already there.

Suggested change
return users.length ? users : eventType.users;
organization: {
select: {
slug: true,
},
},

🤖 Grapple PR auto-fix • major • confidence: 100%


// Fallback to Cal Video if Google Meet is selected w/o a Google Cal
if (evt.location === MeetLocationType && evt.destinationCalendar?.integration !== "google_calendar") {
// @NOTE: destinationCalendar it's an array now so as a fallback we will only check the first one

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — performance agent (Small fix (2 lines, 1 file))

Potential null dereference when destinationCalendar array is empty: destructuring [mainHostDestinationCalendar] from an empty array yields undefined, and then mainHostDestinationCalendar.integration is accessed without a null check — this will throw a TypeError at runtime for events with no destination calendar configured.

Suggested change
// @NOTE: destinationCalendar it's an array now so as a fallback we will only check the first one
if (evt.location === MeetLocationType && mainHostDestinationCalendar?.integration !== "google_calendar") {

🤖 Grapple PR auto-fix • major • confidence: 100%

...(IS_TEAM_BILLING_ENABLED && { requestedSlug: slug }),
...(IS_TEAM_BILLING_ENABLED ? { requestedSlug: slug } : {}),
isOrganization: true,
isOrganizationVerified: false,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — logic agent (Small fix (4 lines, 1 file))

The logic for slug and requestedSlug was inverted. Originally, slug was set when billing was NOT enabled (!IS_TEAM_BILLING_ENABLED), and requestedSlug was set when billing WAS enabled. The new code does the opposite: slug is set when IS_TEAM_BILLING_ENABLED is true and requestedSlug is set when IS_TEAM_BILLING_ENABLED is true. This reverses the intended business logic.

Suggested change
isOrganizationVerified: false,
...(!IS_TEAM_BILLING_ENABLED && { slug }),
...(IS_TEAM_BILLING_ENABLED && { requestedSlug: slug }),

🤖 Grapple PR auto-fix • minor • confidence: 100%

: organizerUser.destinationCalendar
? [organizerUser.destinationCalendar]
: null,
hideCalendarNotes: eventType.hideCalendarNotes,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — logic agent (Small fix (2 lines, 1 file))

The destinationCalendar is set to null when no calendars are available, but other files in this PR (handleCancelBooking, confirm.handler, webhook, etc.) use [] (empty array) as the fallback. This inconsistency could cause issues where code checks destinationCalendar.length on a null value.

Suggested change
hideCalendarNotes: eventType.hideCalendarNotes,
: [],

🤖 Grapple PR auto-fix • minor • confidence: 100%

name,
...(!IS_TEAM_BILLING_ENABLED && { slug }),
...(IS_TEAM_BILLING_ENABLED ? { slug } : {}),
metadata: {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — style agent (Small fix (4 lines, 1 file))

Inconsistent ternary operator pattern used for conditional object spread. Changed from !IS_TEAM_BILLING_ENABLED && { slug } to IS_TEAM_BILLING_ENABLED ? { slug } : {}, which changes the logic flow and readability.

Suggested change
metadata: {
...(!IS_TEAM_BILLING_ENABLED && { slug }),
...(IS_TEAM_BILLING_ENABLED && { requestedSlug: slug }),

🤖 Grapple PR auto-fix • minor • confidence: 100%

};

async createEvent(calEventRaw: CalendarEvent): Promise<NewCalendarEventType> {
async createEvent(calEventRaw: CalendarEvent, credentialId: number): Promise<NewCalendarEventType> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — style agent (Small fix (2 lines, 1 file))

Method signature changed to add credentialId: number parameter, but this breaks the Calendar interface contract. The interface definition in packages/types/Calendar.d.ts shows only createEvent(event: CalendarEvent, credentialId: number) but other calendar service implementations (Lark, Office365) do not yet accept this parameter.

Suggested change
async createEvent(calEventRaw: CalendarEvent, credentialId: number): Promise<NewCalendarEventType> {
async createEvent(event: CalendarEvent, _credentialId?: number): Promise<NewCalendarEventType> {

🤖 Grapple PR auto-fix • minor • confidence: 93%

translate: await getTranslation(user.locale ?? "en", "common"),
locale: user.locale ?? "en",
},
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — logic agent (Small fix (4 lines, 1 file))

The teamDestinationCalendars array only collects destination calendars for users.slice(1) (non-organizer users). The organizer's destination calendar is set separately. However, for ROUND_ROBIN scheduling, destination calendars of team members are not collected at all since the condition checks eventType.schedulingType === 'COLLECTIVE' only.

Suggested change
};
// push to teamDestinationCalendars if it's a team event (collective or round-robin)
if (isTeamEventType && user.destinationCalendar) {

🤖 Grapple PR auto-fix • minor • confidence: 89%

@@ -244,7 +253,7 @@ export default class GoogleCalendarService implements Calendar {

const selectedCalendar = externalCalendarId
? externalCalendarId

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — logic agent (Small fix (4 lines, 1 file))

In updateEvent, the fallback expression event.destinationCalendar?.find((cal) => cal.externalId === externalCalendarId)?.externalId is used when externalCalendarId is falsy. But since the ternary condition already checks externalCalendarId is truthy, the else branch executes only when externalCalendarId is falsy/undefined. The .find() then searches for cal.externalId === undefined, which will almost never match a real calendar, resulting in selectedCalendar being undefined. This means updates will fail or go to the wrong calendar.

--- a/packages/app-store/googlecalendar/lib/CalendarService.ts
+++ b/packages/app-store/googlecalendar/lib/CalendarService.ts
@@ -253,7 +253,7 @@ export default class GoogleCalendarService implements Calendar {
 
       const selectedCalendar = externalCalendarId
         ? externalCalendarId
-        : event.destinationCalendar?.find((cal) => cal.externalId === externalCalendarId)?.externalId;
+        : mainHostDestinationCalendar?.externalId;
 
       calendar.events.update(
         {
@@ -312,7 +312,7 @@ export default class GoogleCalendarService implements Calendar {
 
       const calendarId = externalCalendarId
         ? externalCalendarId
-        : event.destinationCalendar?.find((cal) => cal.externalId === externalCalendarId)?.externalId;
+        : mainHostDestinationCalendar?.externalId;
 
       calendar.events.delete(
         {

🤖 Grapple PR auto-fix • critical • Review this diff before applying

@@ -303,7 +312,9 @@ export default class GoogleCalendarService implements Calendar {
});

const defaultCalendarId = "primary";

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — logic agent (Small fix (8 lines, 1 file))

In deleteEvent, the exact same logic bug as updateEvent: the fallback branch searches event.destinationCalendar?.find((cal) => cal.externalId === externalCalendarId)?.externalId but this branch only executes when externalCalendarId is falsy, so find() will search for cal.externalId === undefined and return nothing. Calendar event deletions will fail to find the correct calendar ID.

--- a/packages/app-store/googlecalendar/lib/CalendarService.ts
+++ b/packages/app-store/googlecalendar/lib/CalendarService.ts
@@ -250,7 +250,7 @@ export default class GoogleCalendarService implements Calendar {
 
       const selectedCalendar = externalCalendarId
         ? externalCalendarId
-        : event.destinationCalendar?.find((cal) => cal.externalId === externalCalendarId)?.externalId;
+        : mainHostDestinationCalendar?.externalId;
 
       calendar.events.update(
         {
@@ -309,6 +309,9 @@ export default class GoogleCalendarService implements Calendar {
   async deleteEvent(uid: string, event: CalendarEvent, externalCalendarId?: string | null): Promise<void> {
     return new Promise(async (resolve, reject) => {
       const myGoogleAuth = await this.auth.getToken();
+      const [mainHostDestinationCalendar] =
+        event?.destinationCalendar && event?.destinationCalendar.length > 0
+          ? event.destinationCalendar
+          : [];
       const calendar = google.calendar({
         version: "v3",
         auth: myGoogleAuth,
@@ -316,7 +319,7 @@ export default class GoogleCalendarService implements Calendar {
       const defaultCalendarId = "primary";
       const calendarId = externalCalendarId
         ? externalCalendarId
-        : event.destinationCalendar?.find((cal) => cal.externalId === externalCalendarId)?.externalId;
+        : mainHostDestinationCalendar?.externalId;
 
       calendar.events.delete(
         {

🤖 Grapple PR auto-fix • critical • Review this diff before applying

);
}
} else {
/**

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — performance agent (Larger fix (145 lines, 1 file) — review recommended)

Database query inside a loop: prisma.credential.findUnique is called inside a for...of loop over event.destinationCalendar. For a collective event with N hosts, this issues N sequential DB queries. Combined with the same pattern in updateAllCalendarEvents, this could cause significant latency on bookings with many hosts.

--- a/packages/core/EventManager.ts
+++ b/packages/core/EventManager.ts
@@ -334,52 +334,68 @@ export default class EventManager {
    * @private
    */
   private async createAllCalendarEvents(event: CalendarEvent) {
     let createdEvents: EventResult<NewCalendarEventType>[] = [];
     if (event.destinationCalendar && event.destinationCalendar.length > 0) {
+      // Batch-fetch any credentials not already in memory to avoid N+1 queries
+      const missingCredentialIds = event.destinationCalendar
+        .filter(
+          (destination) =>
+            destination.credentialId &&
+            !this.calendarCredentials.find((c) => c.id === destination.credentialId)
+        )
+        .map((destination) => destination.credentialId as number);
+
+      const fetchedCredentialsMap = new Map<number, CredentialPayload>();
+      if (missingCredentialIds.length > 0) {
+        const credentialsFromDB = await prisma.credential.findMany({
+          include: {
+            app: {
+              select: {
+                slug: true,
+              },
+            },
+          },
+          where: {
+            id: { in: missingCredentialIds },
+          },
+        });
+        for (const cred of credentialsFromDB) {
+          if (cred.app?.slug) {
+            fetchedCredentialsMap.set(cred.id, {
+              appName: cred.app.slug,
+              id: cred.id,
+              type: cred.type,
+              key: cred.key,
+              userId: cred.userId,
+              teamId: cred.teamId,
+              invalid: cred.invalid,
+              appId: cred.appId,
+            });
+          }
+        }
+      }
+
       for (const destination of event.destinationCalendar) {
         if (destination.credentialId) {
-          let credential = this.calendarCredentials.find((c) => c.id === destination.credentialId);
-          if (!credential) {
-            // Fetch credential from DB
-            const credentialFromDB = await prisma.credential.findUnique({
-              include: {
-                app: {
-                  select: {
-                    slug: true,
-                  },
-                },
-              },
-              where: {
-                id: destination.credentialId,
-              },
-            });
-            if (credentialFromDB && credentialFromDB.app?.slug) {
-              credential = {
-                appName: credentialFromDB?.app.slug ?? "",
-                id: credentialFromDB.id,
-                type: credentialFromDB.type,
-                key: credentialFromDB.key,
-                userId: credentialFromDB.userId,
-                teamId: credentialFromDB.teamId,
-                invalid: credentialFromDB.invalid,
-                appId: credentialFromDB.appId,
-              };
-            }
-          }
+          const credential =
+            this.calendarCredentials.find((c) => c.id === destination.credentialId) ??
+            fetchedCredentialsMap.get(destination.credentialId);
           if (credential) {
             const createdEvent = await createEvent(credential, event, destination.externalId);
             if (createdEvent) {
               createdEvents.push(createdEvent);
             }
           }
         } else {
           const destinationCalendarCredentials = this.calendarCredentials.filter(
             (c) => c.type === destination.integration
           );
           createdEvents = createdEvents.concat(
             await Promise.all(destinationCalendarCredentials.map(async (c) => await createEvent(c, event)))
           );
         }
       }
     } else {
@@ -493,62 +509,78 @@ export default class EventManager {
       calendarReference = newBooking?.references.length
         ? newBooking.references.filter((reference) => reference.type.includes("_calendar"))
         : booking.references.filter((reference) => reference.type.includes("_calendar"));
 
       if (calendarReference.length === 0) {
         return [];
       }
-      // process all calendar references
+
+      // Batch-fetch any credentials not already in memory to avoid N+1 queries
+      const missingCredentialIds = calendarReference
+        .filter(
+          (reference) =>
+            reference.credentialId &&
+            !this.calendarCredentials.find((c) => c.id === reference.credentialId)
+        )
+        .map((reference) => reference.credentialId as number);
+
+      const fetchedCredentialsMap = new Map<number, CredentialPayload>();
+      if (missingCredentialIds.length > 0) {
+        const credentialsFromDB = await prisma.credential.findMany({
+          include: {
+            app: {
+              select: {
+                slug: true,
+              },
+            },
+          },
+          where: {
+            id: { in: missingCredentialIds },
+          },
+        });
+        for (const cred of credentialsFromDB) {
+          if (cred.app?.slug) {
+            fetchedCredentialsMap.set(cred.id, {
+              appName: cred.app.slug,
+              id: cred.id,
+              type: cred.type,
+              key: cred.key,
+              userId: cred.userId,
+              teamId: cred.teamId,
+              invalid: cred.invalid,
+              appId: cred.appId,
+            });
+          }
+        }
+      }
+
+      // Process all calendar references using the pre-fetched credential map
       let result = [];
       for (const reference of calendarReference) {
         const { uid: bookingRefUid, externalCalendarId: bookingExternalCalendarId } = reference;
         let calenderExternalId: string | null = null;
         if (bookingExternalCalendarId) {
           calenderExternalId = bookingExternalCalendarId;
         }
 
         if (reference.credentialId) {
-          credential = this.calendarCredentials.filter(
-            (credential) => credential.id === reference?.credentialId
-          )[0];
-          if (!credential) {
-            // Fetch credential from DB
-            const credentialFromDB = await prisma.credential.findUnique({
-              include: {
-                app: {
-                  select: {
-                    slug: true,
-                  },
-                },
-              },
-              where: {
-                id: reference.credentialId,
-              },
-            });
-            if (credentialFromDB && credentialFromDB.app?.slug) {
-              credential = {
-                appName: credentialFromDB?.app.slug ?? "",
-                id: credentialFromDB.id,
-                type: credentialFromDB.type,
-                key: credentialFromDB.key,
-                userId: credentialFromDB.userId,
-                teamId: credentialFromDB.teamId,
-                invalid: credentialFromDB.invalid,
-                appId: credentialFromDB.appId,
-              };
-            }
-          }
+          credential =
+            this.calendarCredentials.find((c) => c.id === reference.credentialId) ??
+            fetchedCredentialsMap.get(reference.credentialId);
           result.push(updateEvent(credential, event, bookingRefUid, calenderExternalId));
         } else {
           const credentials = this.calendarCredentials.filter(
             (credential) => credential.type === reference?.type
           );
           for (const credential of credentials) {
             result.push(updateEvent(credential, event, bookingRefUid, calenderExternalId));
           }
         }
       }

🤖 Grapple PR auto-fix • critical • Review this diff before applying

}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — performance agent (Larger fix (36 lines, 1 file) — review recommended)

Database query inside a loop over bookingCalendarReference: prisma.credential.findUnique is called sequentially for each calendar reference whose credential isn't found in the user's loaded credentials. For a collective event with N hosts, this results in up to N sequential DB queries during cancellation.

--- a/packages/features/bookings/lib/handleCancelBooking.ts
+++ b/packages/features/bookings/lib/handleCancelBooking.ts
@@ -415,7 +415,7 @@ async function handler(req: CustomRequest) {
 
   const apiDeletes = [];
 
-  const bookingCalendarReference = bookingToDelete.references.filter((reference) =>
+  const bookingCalendarReference = bookingToDelete.references.filter((reference) =>
     reference.type.includes("_calendar")
   );
 
@@ -418,6 +418,26 @@ async function handler(req: CustomRequest) {
   if (bookingCalendarReference.length > 0) {
+    // Collect all credentialIds that are missing from the already-loaded user credentials
+    // so we can fetch them in a single batched DB query instead of one query per reference (N+1 fix).
+    const loadedCredentialIds = new Set(bookingToDelete.user.credentials.map((c) => c.id));
+    const missingCredentialIds = bookingCalendarReference
+      .map((ref) => ref.credentialId)
+      .filter((id): id is number => id !== null && id !== undefined && !loadedCredentialIds.has(id));
+
+    // Batch-fetch all missing credentials in a single query
+    const fetchedCredentials =
+      missingCredentialIds.length > 0
+        ? await prisma.credential.findMany({
+            where: {
+              id: { in: missingCredentialIds },
+            },
+          })
+        : [];
+
+    // Build a map for O(1) lookup: credentialId -> credential
+    const fetchedCredentialMap = new Map(fetchedCredentials.map((c) => [c.id, c]));
+
     for (const reference of bookingCalendarReference) {
       const { credentialId, uid, externalCalendarId } = reference;
       // If the booking calendar reference contains a credentialId
       if (credentialId) {
         // Find the correct calendar credential under user credentials
         let calendarCredential = bookingToDelete.user.credentials.find(
           (credential) => credential.id === credentialId
         );
         if (!calendarCredential) {
-          // get credential from DB
-          const foundCalendarCredential = await prisma.credential.findUnique({
-            where: {
-              id: credentialId,
-            },
-          });
-          if (foundCalendarCredential) {
-            calendarCredential = foundCalendarCredential;
-          }
+          // Use the pre-fetched credential from the batch query (avoids N+1 per loop iteration)
+          const foundCalendarCredential = fetchedCredentialMap.get(credentialId);
+          if (foundCalendarCredential) {
+            calendarCredential = foundCalendarCredential;
+          }
         }
         if (calendarCredential) {
           const calendar = await getCalendar(calendarCredential);

🤖 Grapple PR auto-fix • critical • Review this diff before applying

: organizerUser.destinationCalendar
? [organizerUser.destinationCalendar]
: null,
hideCalendarNotes: eventType.hideCalendarNotes,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — logic agent (Larger fix (23 lines, 1 file) — review recommended)

The destinationCalendar is wrapped in an array for single-host flows, but the booking database record only connects to evt.destinationCalendar[0].id (line 1877). For collective events, only the first host's destination calendar is persisted to the booking record. This means on cancel/reschedule, only one destination calendar reference is available from the booking, potentially missing other hosts' calendars.

--- a/packages/features/bookings/lib/handleNewBooking.ts
+++ b/packages/features/bookings/lib/handleNewBooking.ts
@@ -1060,10 +1060,14 @@ async function handler(
     attendees: attendeesList,
     location: bookingLocation, // Will be processed by the EventManager later.
     conferenceCredentialId,
+    /**
+     * destinationCalendar is an array to support collective/round-robin events where each host
+     * may have their own calendar. Index [0] is always the event-type-level or organizer-level
+     * calendar and is the one persisted to the booking DB record (single relation).
+     * On cancel/reschedule, hosts' calendars beyond [0] must be re-fetched from the event type
+     * hosts, as only destinationCalendar[0] is stored on the Booking model.
+     */
     destinationCalendar: eventType.destinationCalendar
       ? [eventType.destinationCalendar]
       : organizerUser.destinationCalendar
       ? [organizerUser.destinationCalendar]
       : null,
     hideCalendarNotes: eventType.hideCalendarNotes,
@@ -1871,10 +1871,12 @@ async function handler(
-      destinationCalendar:
-        evt.destinationCalendar && evt.destinationCalendar.length > 0
-          ? {
-              connect: { id: evt.destinationCalendar[0].id },
-            }
-          : undefined,
+      // Only evt.destinationCalendar[0] (event-type or organizer calendar) is persisted here.
+      // Collective-event host calendars (indices 1+) are used for in-flight calendar operations
+      // but cannot be stored due to the Booking model's single destinationCalendar relation.
+      // cancel/reschedule handlers must re-derive host calendars from the event type hosts.
+      destinationCalendar:
+        evt.destinationCalendar && evt.destinationCalendar.length > 0
+          ? {
+              connect: { id: evt.destinationCalendar[0].id },
+            }
+          : undefined,
     };

🤖 Grapple PR auto-fix • major • Review this diff before applying

}
}
if (credential) {
const createdEvent = await createEvent(credential, event, destination.externalId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — logic agent (Small fix (6 lines, 1 file))

When fetching a credential from DB in createAllCalendarEvents, the constructed credential object is missing the appName field (it uses app.slug instead) and is missing several fields that CredentialWithAppName may require (like user, selectedCalendars, etc.). The same issue exists in the updateAllCalendarEvents credential fallback. This may cause downstream errors in calendar service implementations that depend on these fields.

--- a/packages/core/EventManager.ts
+++ b/packages/core/EventManager.ts
@@ -340,7 +340,8 @@ export default class EventManager {
             const credentialFromDB = await prisma.credential.findUnique({
               include: {
                 app: {
                   select: {
+                    name: true,
                     slug: true,
                   },
                 },
@@ -351,7 +352,7 @@ export default class EventManager {
             });
             if (credentialFromDB && credentialFromDB.app?.slug) {
               credential = {
-                appName: credentialFromDB?.app.slug ?? "",
+                appName: credentialFromDB.app.name ?? credentialFromDB.app.slug ?? "",
                 id: credentialFromDB.id,
                 type: credentialFromDB.type,
                 key: credentialFromDB.key,
@@ -510,7 +511,8 @@ export default class EventManager {
             const credentialFromDB = await prisma.credential.findUnique({
               include: {
                 app: {
                   select: {
+                    name: true,
                     slug: true,
                   },
                 },
@@ -521,7 +522,7 @@ export default class EventManager {
             });
             if (credentialFromDB && credentialFromDB.app?.slug) {
               credential = {
-                appName: credentialFromDB?.app.slug ?? "",
+                appName: credentialFromDB.app.name ?? credentialFromDB.app.slug ?? "",
                 id: credentialFromDB.id,
                 type: credentialFromDB.type,
                 key: credentialFromDB.key,

🤖 Grapple PR auto-fix • major • Review this diff before applying

};

async createEvent(calEventRaw: CalendarEvent): Promise<NewCalendarEventType> {
async createEvent(calEventRaw: CalendarEvent, credentialId: number): Promise<NewCalendarEventType> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — logic agent (Larger fix (5 lines, 2 files) — review recommended)

The createEvent method signature now requires a credentialId parameter, but the Calendar interface in Calendar.d.ts declares createEvent(event: CalendarEvent, credentialId: number). Calendar services that don't use credentialId (Lark, Office365, CalDAV/BaseCalendarService) don't accept the second parameter in their implementations. While TypeScript may allow extra unused params, Lark's createEvent signature on line 125 does not include it, creating an interface mismatch.

--- a/packages/app-store/larkcalendar/lib/CalendarService.ts
+++ b/packages/app-store/larkcalendar/lib/CalendarService.ts
@@ -122,7 +122,7 @@ export default class LarkCalendarService implements Calendar {
     };
   }
 
-  async createEvent(event: CalendarEvent): Promise<NewCalendarEventType> {
+  async createEvent(event: CalendarEvent, credentialId: number): Promise<NewCalendarEventType> {
     let eventId = "";
     let eventRespData;
     const mainHostDestinationCalendar = event.destinationCalendar
--- a/packages/app-store/office365calendar/lib/CalendarService.ts
+++ b/packages/app-store/office365calendar/lib/CalendarService.ts
@@ -69,7 +69,7 @@ export default class Office365CalendarService implements Calendar {
     };
   }
 
-  async createEvent(event: CalendarEvent): Promise<NewCalendarEventType> {
+  async createEvent(event: CalendarEvent, credentialId: number): Promise<NewCalendarEventType> {
     const calendarId = event.destinationCalendar?.find(
       (cal) => cal.integration === "office365_calendar"
     )?.externalId;
--- a/packages/lib/CalendarService.ts
+++ b/packages/lib/CalendarService.ts
@@ -1,6 +1,7 @@
 import type {
   Calendar,
   CalendarEvent,
+  NewCalendarEventType,
 } from "@calcom/types/Calendar";
 
 // Ensure any abstract/base createEvent signature matches the interface

🤖 Grapple PR auto-fix • major • Review this diff before applying

? externalCalendarId
: event.destinationCalendar?.externalId;
: event.destinationCalendar?.find((cal) => cal.externalId === externalCalendarId)?.externalId;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — performance agent (Small fix (4 lines, 1 file))

Dead-code logic in updateEvent: the selectedCalendar fallback uses Array.find to search by externalCalendarId but the condition cal.externalId === externalCalendarId will always be true for the matched element and is used only when externalCalendarId is falsy — making the entire .find() call unreachable/pointless. The same pattern exists in deleteEvent.

--- a/packages/app-store/googlecalendar/lib/CalendarService.ts
+++ b/packages/app-store/googlecalendar/lib/CalendarService.ts
@@ -253,7 +253,7 @@ export default class GoogleCalendarService implements Calendar {
 
       const selectedCalendar = externalCalendarId
         ? externalCalendarId
-        : event.destinationCalendar?.find((cal) => cal.externalId === externalCalendarId)?.externalId;
+        : mainHostDestinationCalendar?.externalId;
 
       calendar.events.update(
         {
@@ -312,7 +312,7 @@ export default class GoogleCalendarService implements Calendar {
       const defaultCalendarId = "primary";
       const calendarId = externalCalendarId
         ? externalCalendarId
-        : event.destinationCalendar?.find((cal) => cal.externalId === externalCalendarId)?.externalId;
+        : mainHostDestinationCalendar?.externalId;
 
       calendar.events.delete(
         {

🤖 Grapple PR auto-fix • major • Review this diff before applying


/* Used for seats bookings to update evt object with video data */
const addVideoCallDataToEvt = (bookingReferences: BookingReference[]) => {
const videoCallReference = bookingReferences.find((reference) => reference.type.includes("_video"));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — performance agent (Larger fix (29 lines, 1 file) — review recommended)

Mutating evt.destinationCalendar with .push() after initial construction is fragile and can cause race conditions or missed pushes if the array is null. The destinationCalendar is initially set to null (not []) when neither eventType.destinationCalendar nor organizerUser.destinationCalendar exists, so the .push() call will throw at runtime.

--- a/packages/features/bookings/lib/handleNewBooking.ts
+++ b/packages/features/bookings/lib/handleNewBooking.ts
@@ -1060,18 +1060,28 @@ async function handler(
     attendees: attendeesList,
     location: bookingLocation, // Will be processed by the EventManager later.
     conferenceCredentialId,
-    destinationCalendar: eventType.destinationCalendar
-      ? [eventType.destinationCalendar]
-      : organizerUser.destinationCalendar
-      ? [organizerUser.destinationCalendar]
-      : null,
+    destinationCalendar: (() => {
+      // Build the base destination calendar list from the organizer/event-type calendar.
+      const baseCalendars: DestinationCalendar[] = eventType.destinationCalendar
+        ? [eventType.destinationCalendar]
+        : organizerUser.destinationCalendar
+        ? [organizerUser.destinationCalendar]
+        : [];
+
+      // For collective team events, merge in per-member destination calendars collected
+      // during teamMemberPromises. We do this at construction time rather than via a
+      // post-hoc .push() so the array is never null and no calendars are silently lost.
+      const calendars =
+        isTeamEventType && eventType.schedulingType === "COLLECTIVE"
+          ? [...baseCalendars, ...teamDestinationCalendars]
+          : baseCalendars;
+
+      // Return null only when there are genuinely no calendars, preserving the
+      // downstream null-check semantics used by EventManager and the booking create call.
+      return calendars.length > 0 ? calendars : null;
+    })(),
     hideCalendarNotes: eventType.hideCalendarNotes,
     requiresConfirmation: requiresConfirmation ?? false,
     eventTypeId: eventType.id,
     // if seats are not enabled we should default true
     seatsShowAttendees: eventType.seatsPerTimeSlot ? eventType.seatsShowAttendees : true,
     seatsPerTimeSlot: eventType.seatsPerTimeSlot,
     schedulingType: eventType.schedulingType,
   };
 
-  if (isTeamEventType && eventType.schedulingType === "COLLECTIVE") {
-    evt.destinationCalendar?.push(...teamDestinationCalendars);
-  }
-
   /* Used for seats bookings to update evt object with video data */

🤖 Grapple PR auto-fix • major • Review this diff before applying

location: bookingToReschedule?.location,
destinationCalendar: bookingToReschedule?.destinationCalendar || bookingToReschedule?.destinationCalendar,
destinationCalendar: bookingToReschedule?.destinationCalendar
? [bookingToReschedule?.destinationCalendar]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — performance agent (Larger fix (13 lines, 1 file) — review recommended)

The original code before this PR had a self-referential fallback (bookingToReschedule?.destinationCalendar || bookingToReschedule?.destinationCalendar) which was already a bug. The new code only wraps the booking-level destination calendar and does not account for collective event hosts' destination calendars, unlike the pattern used in other handlers.

--- a/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts
+++ b/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts
@@ -237,7 +237,11 @@ export const requestRescheduleHandler = async ({ ctx, input }: RequestReschedule
     ),
     uid: bookingToReschedule?.uid,
     location: bookingToReschedule?.location,
-    destinationCalendar: bookingToReschedule?.destinationCalendar
-      ? [bookingToReschedule?.destinationCalendar]
-      : [],
+    // Prefer the booking-level destination calendar; fall back to the organizer's
+    // destination calendar (matching the pattern in confirm, cancel, editLocation handlers).
+    // This ensures reschedule operations work correctly even when the booking record
+    // has no stored destinationCalendar (e.g. older bookings or collective event types
+    // where the calendar is stored at the user level).
+    destinationCalendar: bookingToReschedule?.destinationCalendar
+      ? [bookingToReschedule.destinationCalendar]
+      : userFromQuery?.destinationCalendar
+      ? [userFromQuery.destinationCalendar]
+      : [],
     cancellationReason: `Please reschedule. ${cancellationReason}`, // TODO::Add i18-next for this
   };

🤖 Grapple PR auto-fix • minor • Review this diff before applying

@Frawatson

Copy link
Copy Markdown
Owner Author

@grapple-pr review

@grapple-pr grapple-pr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 Grapple PR Review

Summary

This PR fixes handling of destination calendars when a collective event type has multiple hosts, ensuring that calendar events are created/updated/deleted on the correct calendars for each host rather than failing or using incorrect calendar targets. The changes touch the core EventManager, CalendarManager, calendar service integrations (Google, Lark, Office365), and booking lifecycle handlers.

Changes: 22 files (+368 / -216 lines)

  • 🟡 22 files modified: apps/web/pages/api/cron/bookingReminder.ts, apps/web/playwright/webhook.e2e.ts, packages/app-store/googlecalendar/lib/CalendarService.ts, packages/app-store/larkcalendar/lib/CalendarService.ts, packages/app-store/office365calendar/lib/CalendarService.ts +17 more

Resolves: #4 (OAuth credential sync and app integration enhancements)

CI: ⏳ 0 passed, 0 failed

Impact Diagram

graph TD
  pr_0[/"🔀 PR Changes: 22 files"/]
  f1["🟡 bookingReminder.ts +2/-2"]
  pr_0 --> f1
  f2["🟡 webhook.e2e.ts +1/-1"]
  pr_0 --> f2
  f3["🟡 CalendarService.ts +22/-11"]
  pr_0 --> f3
  f4["🟡 CalendarService.ts +8/-4"]
  pr_0 --> f4
  f5["🟡 CalendarService.ts +3/-2"]
  pr_0 --> f5
  f6["🟡 CalendarManager.ts +24/-19"]
  pr_0 --> f6
  f7["🟡 EventManager.ts +124/-68"]
  pr_0 --> f7
  f8["🟡 builder.ts +2/-1"]
  pr_0 --> f8
  f9["🟡 class.ts +1/-1"]
  pr_0 --> f9
  f10["🟡 BrokenIntegrationEmail.tsx +3/-2"]
  pr_0 --> f10
  f11["🟡 handleCancelBooking.ts +65/-44"]
  pr_0 --> f11
  f12["🟡 handleNewBooking.ts +72/-43"]
  pr_0 --> f12
  more_13["... +10 more files"]
  pr_0 --> more_13
  f6 -.- f7
  f8 -.- f9
  f11 -.- f12
  style f1 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f2 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f3 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f4 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f5 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f6 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f7 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f8 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f9 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f10 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f11 fill:#7c3aed,color:#fff,stroke:#7c3aed
  style f12 fill:#7c3aed,color:#fff,stroke:#7c3aed
Loading

1 critical issue found — please address before merging.

Severity Count
🔴 Critical 1
🟠 Major 6
🟡 Minor 7
🔵 Info 1
Review details
  • Reviewed at: 2026-04-12T02:15:47.763Z
  • Agents used: security, logic, style, architecture, performance, verification
  • Context level: full
  • Execution time: 187.1s
  • Low-confidence findings suppressed: 7
  • Blast radius: 0 (contained)

Powered by Grapple PR — AI that understands your code.


// Fallback to Cal Video if Google Meet is selected w/o a Google Cal
if (evt.location === MeetLocationType && evt.destinationCalendar?.integration !== "google_calendar") {
// @NOTE: destinationCalendar it's an array now so as a fallback we will only check the first one

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 CRITICAL — Null Dereference / Denial of Service (confidence: 100%)

After destructuring evt.destinationCalendar ?? [], mainHostDestinationCalendar will be undefined when the array is empty. Accessing .integration on undefined throws a TypeError, crashing the booking flow. An attacker booking a collective event type with no configured destination calendars can trigger this crash.

Evidence:

  • Line 115: const [mainHostDestinationCalendar] = evt.destinationCalendar ?? []; — if destinationCalendar is [] (empty array, which is valid per the new type), mainHostDestinationCalendar is undefined
  • Line 117: mainHostDestinationCalendar.integration !== 'google_calendar' — no null/undefined guard before property access
  • Edge case explicitly listed: 'A collective event where one host has no destinationCalendar configured — should fall back gracefully without throwing'
  • The CalendarEvent builder class.ts line 26 now allows DestinationCalendar[] | null, and handleNewBooking can produce null destinationCalendar

Agent: security

);
createdEvents = createdEvents.concat(
await Promise.all(destinationCalendarCredentials.map(async (c) => await createEvent(c, event)))
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 MAJOR — Missing Ownership Verification on Credential Fetch (IDOR) (confidence: 100%)

When a calendar credential is not found in this.calendarCredentials, the code fetches it from the database by ID alone (prisma.credential.findUnique({ where: { id: destination.credentialId } })) without verifying that the credential belongs to the user performing the booking. If credentialId can be attacker-influenced, this allows use of another user's calendar credential.

Evidence:

  • The fetched credential has no userId ownership check against the organizer or any event host
  • The same pattern appears in updateAllCalendarEvents (around line 520) for calendar references
  • While destinationCalendar.credentialId comes from DB-stored data, the absence of ownership verification is a defense-in-depth failure
  • The credential is then used to call createEvent(credential, event, ...) which performs authenticated calendar API operations

Agent: security

if (foundCalendarCredential) {
calendarCredential = foundCalendarCredential;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 MAJOR — Missing Ownership Verification on Credential Fetch (IDOR) (confidence: 100%)

When a calendar credential is not found in the user's loaded credentials, the code fetches it directly from the database by ID without verifying that foundCalendarCredential.userId matches bookingToDelete.user.id. This could allow calendar delete operations using credentials belonging to other users.

Evidence:

  • Lines 428-439: const foundCalendarCredential = await prisma.credential.findUnique({ where: { id: credentialId } }) — no userId ownership filter
  • The credential is then used to call getCalendar(calendarCredential) and calendar.deleteEvent(uid, evt, externalCalendarId)
  • credentialId comes from bookingToDelete.references which is DB-stored, but defense-in-depth requires ownership validation
  • A compromised booking reference (e.g., via a bug elsewhere) could cause deletion on another user's calendar

Agent: security

@@ -244,7 +253,7 @@ export default class GoogleCalendarService implements Calendar {

const selectedCalendar = externalCalendarId
? externalCalendarId

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 MAJOR — Logic Error — Tautological Condition Breaks Calendar Fallback (confidence: 100%)

In updateEvent, when externalCalendarId is falsy, the fallback is event.destinationCalendar?.find((cal) => cal.externalId === externalCalendarId)?.externalId. Since externalCalendarId is falsy at this point, find searches for a calendar with externalId === undefined, which will never match. The same tautological pattern exists in deleteEvent. This means the fallback calendar resolution is always broken, causing events to be updated/deleted on an undefined calendar.

Evidence:

  • Line 253: const selectedCalendar = externalCalendarId ? externalCalendarId : event.destinationCalendar?.find((cal) => cal.externalId === externalCalendarId)?.externalId
  • When externalCalendarId is falsy, cal.externalId === externalCalendarId compares against the falsy value itself — this cannot find a valid calendar
  • Same pattern in deleteEvent around line 312-314
  • The original code was event.destinationCalendar?.externalId which would correctly use the first destination calendar; the new code breaks this fallback

Agent: security

);
}
} else {
/**

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 MAJOR — N+1 queries (confidence: 100%)

Database query inside a loop: prisma.credential.findUnique is called inside a for...of loop over event.destinationCalendar. For a collective event with N hosts, this results in N sequential DB round-trips.

Evidence:

  • The loop for (const destination of event.destinationCalendar) iterates over all destination calendars.
  • Inside the loop, await prisma.credential.findUnique({ where: { id: destination.credentialId } }) is called synchronously per iteration.
  • For collective events with many hosts, this is an N+1 query pattern — one query per host credential not found in cache.

Agent: performance

@@ -114,7 +114,9 @@ export default class EventManager {
}

// Fallback to Cal Video if Google Meet is selected w/o a Google Cal

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 MINOR — Unnecessary work (confidence: 100%)

Destructuring [mainHostDestinationCalendar] from evt.destinationCalendar may throw or produce undefined silently if the array is empty, and there is no null guard before accessing .integration.

Evidence:

  • const [mainHostDestinationCalendar] = evt.destinationCalendar ?? [];
  • Immediately followed by: if (evt.location === MeetLocationType && mainHostDestinationCalendar.integration !== 'google_calendar')
  • If destinationCalendar is an empty array, mainHostDestinationCalendar is undefined and .integration access throws a TypeError at runtime.

Agent: performance

@@ -114,7 +114,9 @@ export default class EventManager {
}

// Fallback to Cal Video if Google Meet is selected w/o a Google Cal

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 MINOR — Naming Conventions (confidence: 95%)

Inconsistent destructuring pattern: [mainHostDestinationCalendar] suggests this extracts the 'main host', but for collective events there is no semantic 'main host' — all hosts are peers. This naming is misleading.

Evidence:

  • The variable is used throughout EventManager with the name 'mainHostDestinationCalendar', implying priority or hierarchy
  • In collective scheduling, all hosts should be treated equally; using 'first' or 'primary' in the variable name would better reflect the implementation choice rather than business logic
  • The same pattern appears in GoogleCalendarService, LarkCalendarService, Office365CalendarService, and BaseCalendarService, making the misleading name consistent across the codebase

Agent: style


const defaultCalendarId = "primary";
const calendarId = externalCalendarId ? externalCalendarId : event.destinationCalendar?.externalId;
const calendarId = externalCalendarId

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 MINOR — Unnecessary work (confidence: 93%)

Same no-op fallback pattern in deleteEvent: event.destinationCalendar?.find((cal) => cal.externalId === externalCalendarId)?.externalId when externalCalendarId is falsy will never find a matching entry.

Evidence:

  • const calendarId = externalCalendarId ? externalCalendarId : event.destinationCalendar?.find((cal) => cal.externalId === externalCalendarId)?.externalId;
  • The condition in .find uses the same falsy externalCalendarId as the comparison value.
  • This means the fallback always returns undefined, leaving calendarId undefined instead of falling back to the host's destination calendar.

Agent: performance

if (evt.location === MeetLocationType && evt.destinationCalendar?.integration !== "google_calendar") {
// @NOTE: destinationCalendar it's an array now so as a fallback we will only check the first one
const [mainHostDestinationCalendar] = evt.destinationCalendar ?? [];
if (evt.location === MeetLocationType && mainHostDestinationCalendar.integration !== "google_calendar") {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 MINOR — Documentation (confidence: 87%)

Inline comment references outdated assumption about destinationCalendar being a single object. Comment says 'we will only check the first one' but provides no context for why this fallback strategy was chosen for multi-host collective events.

Evidence:

  • The comment '@note: destinationCalendar it's an array now' is explanatory but doesn't justify why only the first destination calendar is checked for Google Meet integration
  • Future maintainers may incorrectly assume this is the correct behavior for all multi-host scenarios
  • No corresponding TODO or FIXME indicating this is a provisional decision

Agent: style

@@ -1,4 +1,5 @@
import { Prisma, Booking } from "@prisma/client";
import type { Booking } from "@prisma/client";
import { Prisma } from "@prisma/client";

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 INFO — Code Organization (confidence: 84%)

Import statement reorganization changes the order of imports in a way that doesn't follow a clear convention. Type imports are now on line 1 and runtime imports on line 2, but this pattern is not used consistently elsewhere.

Evidence:

  • The change moves Booking to a type import on line 1 and keeps Prisma as a runtime import on line 2
  • Many TypeScript files in the codebase mix type and runtime imports without separation
  • If type-import separation is a new convention, it should be applied uniformly across the codebase, not in isolated files

Agent: style

@grapple-pr grapple-pr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔧 [Grapple PR] 5 auto-fix suggestions available

Apply individually or use GitHub's "Add suggestion to batch""Commit suggestions" to apply all at once.

Commits from Grapple PR are prefixed with [Grapple PR] for easy identification in merge conflicts.


// Fallback to Cal Video if Google Meet is selected w/o a Google Cal
if (evt.location === MeetLocationType && evt.destinationCalendar?.integration !== "google_calendar") {
// @NOTE: destinationCalendar it's an array now so as a fallback we will only check the first one

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — security agent (Small fix (2 lines, 1 file))

After destructuring evt.destinationCalendar ?? [], mainHostDestinationCalendar will be undefined when the array is empty. Accessing .integration on undefined throws a TypeError, crashing the booking flow. An attacker booking a collective event type with no configured destination calendars can trigger this crash.

Suggested change
// @NOTE: destinationCalendar it's an array now so as a fallback we will only check the first one
if (evt.location === MeetLocationType && mainHostDestinationCalendar?.integration !== "google_calendar") {

🤖 Grapple PR auto-fix • critical • confidence: 100%

if (foundCalendarCredential) {
calendarCredential = foundCalendarCredential;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — security agent (Small fix (10 lines, 1 file))

When a calendar credential is not found in the user's loaded credentials, the code fetches it directly from the database by ID without verifying that foundCalendarCredential.userId matches bookingToDelete.user.id. This could allow calendar delete operations using credentials belonging to other users.

Suggested change
}
// get credential from DB — must belong to the booking owner to prevent IDOR
const foundCalendarCredential = await prisma.credential.findFirst({
userId: bookingToDelete.user.id,
} else {
logger.warn(
`Credential ${credentialId} not found or does not belong to user ${bookingToDelete.user.id} — skipping calendar delete for reference`,
{ credentialId, userId: bookingToDelete.user.id }
);

🤖 Grapple PR auto-fix • major • confidence: 100%

where: {
id: reference.credentialId,
},
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — security agent (Small fix (4 lines, 1 file))

In updateAllCalendarEvents, after attempting to fetch a credential from DB when not found in this.calendarCredentials, result.push(updateEvent(credential, event, bookingRefUid, calenderExternalId)) is called even when credential remains undefined (if DB fetch also fails). The credential variable may be undefined here.

Suggested change
});
if (credential) {
result.push(updateEvent(credential, event, bookingRefUid, calenderExternalId));
}

🤖 Grapple PR auto-fix • minor • confidence: 100%

@@ -114,7 +114,9 @@ export default class EventManager {
}

// Fallback to Cal Video if Google Meet is selected w/o a Google Cal

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — performance agent (Small fix (2 lines, 1 file))

Destructuring [mainHostDestinationCalendar] from evt.destinationCalendar may throw or produce undefined silently if the array is empty, and there is no null guard before accessing .integration.

Suggested change
// Fallback to Cal Video if Google Meet is selected w/o a Google Cal
if (evt.location === MeetLocationType && mainHostDestinationCalendar?.integration !== "google_calendar") {

🤖 Grapple PR auto-fix • minor • confidence: 100%

@@ -114,7 +114,9 @@ export default class EventManager {
}

// Fallback to Cal Video if Google Meet is selected w/o a Google Cal

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — style agent (Small fix (4 lines, 1 file))

Inconsistent destructuring pattern: [mainHostDestinationCalendar] suggests this extracts the 'main host', but for collective events there is no semantic 'main host' — all hosts are peers. This naming is misleading.

Suggested change
// Fallback to Cal Video if Google Meet is selected w/o a Google Cal
const [firstDestinationCalendar] = evt.destinationCalendar ?? [];
if (evt.location === MeetLocationType && firstDestinationCalendar?.integration !== "google_calendar") {

🤖 Grapple PR auto-fix • minor • confidence: 95%

);
createdEvents = createdEvents.concat(
await Promise.all(destinationCalendarCredentials.map(async (c) => await createEvent(c, event)))
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — security agent (Larger fix (29 lines, 1 file) — review recommended)

When a calendar credential is not found in this.calendarCredentials, the code fetches it from the database by ID alone (prisma.credential.findUnique({ where: { id: destination.credentialId } })) without verifying that the credential belongs to the user performing the booking. If credentialId can be attacker-influenced, this allows use of another user's calendar credential.

--- a/packages/core/EventManager.ts
+++ b/packages/core/EventManager.ts
@@ -334,6 +334,18 @@ export default class EventManager {
    * @private
    */
   private async createAllCalendarEvents(event: CalendarEvent) {
+    // Collect valid userIds and teamIds from already-authorized credentials
+    // to use for ownership verification when fetching credentials from DB.
+    const authorizedUserIds = new Set(
+      this.calendarCredentials.map((c) => c.userId).filter((id): id is number => id !== null && id !== undefined)
+    );
+    const authorizedTeamIds = new Set(
+      this.calendarCredentials.map((c) => c.teamId).filter((id): id is number => id !== null && id !== undefined)
+    );
+
+    const isAuthorizedCredential = (cred: { userId: number | null; teamId: number | null }) =>
+      (cred.userId !== null && authorizedUserIds.has(cred.userId)) ||
+      (cred.teamId !== null && authorizedTeamIds.has(cred.teamId));
+
     let createdEvents: EventResult<NewCalendarEventType>[] = [];
     if (event.destinationCalendar && event.destinationCalendar.length > 0) {
       for (const destination of event.destinationCalendar) {
@@ -358,6 +370,14 @@ export default class EventManager {
             if (credentialFromDB && credentialFromDB.app?.slug) {
               credential = {
                 appName: credentialFromDB?.app.slug ?? "",
+                // Security: verify the DB-fetched credential belongs to an authorized
+                // user or team before using it. This prevents IDOR where an attacker
+                // could influence credentialId to use another user's calendar credential.
+                ...((): typeof credential => {
+                  if (!isAuthorizedCredential(credentialFromDB)) {
+                    return undefined;
+                  }
+                  return undefined; // placeholder; real assignment below
+                })(),
               };
             }
           }
@@ -358,6 +370,22 @@ export default class EventManager {
             if (credentialFromDB && credentialFromDB.app?.slug) {
+              // Security: verify the DB-fetched credential belongs to an authorized user or team
+              if (!isAuthorizedCredential(credentialFromDB)) {
+                console.warn(
+                  `[EventManager] Unauthorized credential access attempt blocked. credentialId=${destination.credentialId} does not belong to any authorized user or team.`
+                );
+                continue;
+              }
               credential = {
                 appName: credentialFromDB?.app.slug ?? "",
                 id: credentialFromDB.id,
                 type: credentialFromDB.type,
                 key: credentialFromDB.key,
                 userId: credentialFromDB.userId,
                 teamId: credentialFromDB.teamId,
                 invalid: credentialFromDB.invalid,
                 appId: credentialFromDB.appId,
               };
             }

🤖 Grapple PR auto-fix • major • Review this diff before applying

@@ -244,7 +253,7 @@ export default class GoogleCalendarService implements Calendar {

const selectedCalendar = externalCalendarId
? externalCalendarId

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — security agent (Small fix (4 lines, 1 file))

In updateEvent, when externalCalendarId is falsy, the fallback is event.destinationCalendar?.find((cal) => cal.externalId === externalCalendarId)?.externalId. Since externalCalendarId is falsy at this point, find searches for a calendar with externalId === undefined, which will never match. The same tautological pattern exists in deleteEvent. This means the fallback calendar resolution is always broken, causing events to be updated/deleted on an undefined calendar.

--- a/packages/app-store/googlecalendar/lib/CalendarService.ts
+++ b/packages/app-store/googlecalendar/lib/CalendarService.ts
@@ -253,7 +253,7 @@ export default class GoogleCalendarService implements Calendar {
 
       const selectedCalendar = externalCalendarId
         ? externalCalendarId
-        : event.destinationCalendar?.find((cal) => cal.externalId === externalCalendarId)?.externalId;
+        : mainHostDestinationCalendar?.externalId;
 
       calendar.events.update(
         {
@@ -312,7 +312,7 @@ export default class GoogleCalendarService implements Calendar {
       const defaultCalendarId = "primary";
       const calendarId = externalCalendarId
         ? externalCalendarId
-        : event.destinationCalendar?.find((cal) => cal.externalId === externalCalendarId)?.externalId;
+        : mainHostDestinationCalendar?.externalId;
 
       calendar.events.delete(
         {

🤖 Grapple PR auto-fix • major • Review this diff before applying

);
}
} else {
/**

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — performance agent (Larger fix (69 lines, 1 file) — review recommended)

Database query inside a loop: prisma.credential.findUnique is called inside a for...of loop over event.destinationCalendar. For a collective event with N hosts, this results in N sequential DB round-trips.

--- a/packages/core/EventManager.ts
+++ b/packages/core/EventManager.ts
@@ -334,52 +334,61 @@ export default class EventManager {
    * @private
    */
   private async createAllCalendarEvents(event: CalendarEvent) {
     let createdEvents: EventResult<NewCalendarEventType>[] = [];
     if (event.destinationCalendar && event.destinationCalendar.length > 0) {
+      // Collect all credentialIds not already in the in-memory cache so we can
+      // fetch them in a single batch instead of one query per destination (N+1).
+      const missingCredentialIds = event.destinationCalendar
+        .map((d) => d.credentialId)
+        .filter((id): id is number => !!id && !this.calendarCredentials.some((c) => c.id === id));
+
+      // Build a lookup map from the single batched DB query.
+      const fetchedCredentialsMap = new Map<number, CredentialPayload>();
+      if (missingCredentialIds.length > 0) {
+        const credentialsFromDB = await prisma.credential.findMany({
+          where: {
+            id: { in: missingCredentialIds },
+          },
+          include: {
+            app: {
+              select: {
+                slug: true,
+              },
+            },
+          },
+        });
+        for (const credentialFromDB of credentialsFromDB) {
+          if (credentialFromDB.app?.slug) {
+            fetchedCredentialsMap.set(credentialFromDB.id, {
+              appName: credentialFromDB.app.slug,
+              id: credentialFromDB.id,
+              type: credentialFromDB.type,
+              key: credentialFromDB.key,
+              userId: credentialFromDB.userId,
+              teamId: credentialFromDB.teamId,
+              invalid: credentialFromDB.invalid,
+              appId: credentialFromDB.appId,
+            });
+          }
+        }
+      }
+
       for (const destination of event.destinationCalendar) {
         if (destination.credentialId) {
-          let credential = this.calendarCredentials.find((c) => c.id === destination.credentialId);
-          if (!credential) {
-            // Fetch credential from DB
-            const credentialFromDB = await prisma.credential.findUnique({
-              include: {
-                app: {
-                  select: {
-                    slug: true,
-                  },
-                },
-              },
-              where: {
-                id: destination.credentialId,
-              },
-            });
-            if (credentialFromDB && credentialFromDB.app?.slug) {
-              credential = {
-                appName: credentialFromDB?.app.slug ?? "",
-                id: credentialFromDB.id,
-                type: credentialFromDB.type,
-                key: credentialFromDB.key,
-                userId: credentialFromDB.userId,
-                teamId: credentialFromDB.teamId,
-                invalid: credentialFromDB.invalid,
-                appId: credentialFromDB.appId,
-              };
-            }
-          }
+          // Resolve from in-memory cache first, then fall back to the batch-fetched map.
+          const credential =
+            this.calendarCredentials.find((c) => c.id === destination.credentialId) ??
+            fetchedCredentialsMap.get(destination.credentialId);
           if (credential) {
             const createdEvent = await createEvent(credential, event, destination.externalId);
             if (createdEvent) {
               createdEvents.push(createdEvent);
             }
           }
         } else {
           const destinationCalendarCredentials = this.calendarCredentials.filter(
             (c) => c.type === destination.integration
           );
           createdEvents = createdEvents.concat(
             await Promise.all(destinationCalendarCredentials.map(async (c) => await createEvent(c, event)))
           );
         }
       }
     } else {

🤖 Grapple PR auto-fix • major • Review this diff before applying

);
for (const credential of calendarCredentials) {
const calendar = await getCalendar(credential);
apiDeletes.push(calendar?.deleteEvent(uid, evt, externalCalendarId) as Promise<unknown>);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — performance agent (Larger fix (31 lines, 1 file) — review recommended)

prisma.credential.findUnique is called sequentially inside a for...of loop over bookingCalendarReference during cancellation. For collective bookings with multiple calendar references, this results in N sequential DB queries.

--- a/packages/features/bookings/lib/handleCancelBooking.ts
+++ b/packages/features/bookings/lib/handleCancelBooking.ts
@@ -415,6 +415,25 @@ async function handler(req: CustomRequest) {
   const bookingCalendarReference = bookingToDelete.references.filter((reference) =>
     reference.type.includes("_calendar")
   );
 
   if (bookingCalendarReference.length > 0) {
+    // Collect credentialIds that are NOT already in bookingToDelete.user.credentials
+    // so we can batch-fetch them in a single query instead of N sequential findUnique calls.
+    const userCredentialIds = new Set(bookingToDelete.user.credentials.map((c) => c.id));
+    const missingCredentialIds = bookingCalendarReference
+      .map((ref) => ref.credentialId)
+      .filter((id): id is number => id !== null && id !== undefined && !userCredentialIds.has(id));
+
+    // Single batched DB fetch for all missing credentials (replaces N×findUnique inside the loop)
+    const fetchedCredentials =
+      missingCredentialIds.length > 0
+        ? await prisma.credential.findMany({
+            where: {
+              id: { in: missingCredentialIds },
+            },
+          })
+        : [];
+
+    // Build a lookup map so the loop below stays O(1) per reference
+    const fetchedCredentialMap = new Map(fetchedCredentials.map((cred) => [cred.id, cred]));
+
     for (const reference of bookingCalendarReference) {
       const { credentialId, uid, externalCalendarId } = reference;
       // If the booking calendar reference contains a credentialId
@@ -431,14 +450,8 @@ async function handler(req: CustomRequest) {
         let calendarCredential = bookingToDelete.user.credentials.find(
           (credential) => credential.id === credentialId
         );
         if (!calendarCredential) {
-          // get credential from DB
-          const foundCalendarCredential = await prisma.credential.findUnique({
-            where: {
-              id: credentialId,
-            },
-          });
-          if (foundCalendarCredential) {
-            calendarCredential = foundCalendarCredential;
-          }
+          // Use the pre-fetched credential from the batched query above
+          calendarCredential = fetchedCredentialMap.get(credentialId) ?? undefined;
         }
         if (calendarCredential) {

🤖 Grapple PR auto-fix • major • Review this diff before applying

: null,
hideCalendarNotes: eventType.hideCalendarNotes,
requiresConfirmation: requiresConfirmation ?? false,
eventTypeId: eventType.id,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — security agent (Small fix (6 lines, 1 file))

When eventType.destinationCalendar and organizerUser.destinationCalendar are both null/undefined, evt.destinationCalendar is set to null. The subsequent push of teamDestinationCalendars uses optional chaining evt.destinationCalendar?.push(...) which silently no-ops on null, dropping all team member destination calendars.

--- a/packages/features/bookings/lib/handleNewBooking.ts
+++ b/packages/features/bookings/lib/handleNewBooking.ts
@@ -1063,7 +1063,7 @@ async function handler(
     destinationCalendar: eventType.destinationCalendar
       ? [eventType.destinationCalendar]
       : organizerUser.destinationCalendar
       ? [organizerUser.destinationCalendar]
-      : null,
+      : [],
     hideCalendarNotes: eventType.hideCalendarNotes,
     requiresConfirmation: requiresConfirmation ?? false,
     eventTypeId: eventType.id,
@@ -1077,7 +1077,9 @@ async function handler(
   if (isTeamEventType && eventType.schedulingType === "COLLECTIVE") {
-    evt.destinationCalendar?.push(...teamDestinationCalendars);
+    if (teamDestinationCalendars.length > 0) {
+      evt.destinationCalendar = (evt.destinationCalendar ?? []).concat(teamDestinationCalendars);
+    }
   }

🤖 Grapple PR auto-fix • minor • Review this diff before applying

const selectedCalendar = externalCalendarId
? externalCalendarId
: event.destinationCalendar?.externalId;
: event.destinationCalendar?.find((cal) => cal.externalId === externalCalendarId)?.externalId;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — performance agent (Small fix (4 lines, 1 file))

In updateEvent, the selectedCalendar fallback logic is a no-op: when externalCalendarId is falsy, it calls .find(cal => cal.externalId === externalCalendarId) where externalCalendarId is also falsy/null, so the find will always return undefined, making the fallback always resolve to undefined.

--- a/packages/app-store/googlecalendar/lib/CalendarService.ts
+++ b/packages/app-store/googlecalendar/lib/CalendarService.ts
@@ -253,7 +253,7 @@ export default class GoogleCalendarService implements Calendar {
 
       const selectedCalendar = externalCalendarId
         ? externalCalendarId
-        : event.destinationCalendar?.find((cal) => cal.externalId === externalCalendarId)?.externalId;
+        : mainHostDestinationCalendar?.externalId;
 
       calendar.events.update(
         {
@@ -312,7 +312,7 @@ export default class GoogleCalendarService implements Calendar {
 
       const calendarId = externalCalendarId
         ? externalCalendarId
-        : event.destinationCalendar?.find((cal) => cal.externalId === externalCalendarId)?.externalId;
+        : mainHostDestinationCalendar?.externalId;
 
       calendar.events.delete(
         {

🤖 Grapple PR auto-fix • minor • Review this diff before applying


const defaultCalendarId = "primary";
const calendarId = externalCalendarId ? externalCalendarId : event.destinationCalendar?.externalId;
const calendarId = externalCalendarId

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — performance agent (Small fix (4 lines, 1 file))

Same no-op fallback pattern in deleteEvent: event.destinationCalendar?.find((cal) => cal.externalId === externalCalendarId)?.externalId when externalCalendarId is falsy will never find a matching entry.

--- a/packages/app-store/googlecalendar/lib/CalendarService.ts
+++ b/packages/app-store/googlecalendar/lib/CalendarService.ts
@@ -250,7 +250,7 @@ export default class GoogleCalendarService implements Calendar {
 
       const selectedCalendar = externalCalendarId
         ? externalCalendarId
-        : event.destinationCalendar?.find((cal) => cal.externalId === externalCalendarId)?.externalId;
+        : event.destinationCalendar?.[0]?.externalId;
 
       calendar.events.update(
         {
@@ -312,7 +312,7 @@ export default class GoogleCalendarService implements Calendar {
 
       const defaultCalendarId = "primary";
       const calendarId = externalCalendarId
         ? externalCalendarId
-        : event.destinationCalendar?.find((cal) => cal.externalId === externalCalendarId)?.externalId;
+        : event.destinationCalendar?.[0]?.externalId;
 
       calendar.events.delete(
         {

🤖 Grapple PR auto-fix • minor • Review this diff before applying

if (evt.location === MeetLocationType && evt.destinationCalendar?.integration !== "google_calendar") {
// @NOTE: destinationCalendar it's an array now so as a fallback we will only check the first one
const [mainHostDestinationCalendar] = evt.destinationCalendar ?? [];
if (evt.location === MeetLocationType && mainHostDestinationCalendar.integration !== "google_calendar") {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — style agent (Larger fix (14 lines, 1 file) — review recommended)

Inline comment references outdated assumption about destinationCalendar being a single object. Comment says 'we will only check the first one' but provides no context for why this fallback strategy was chosen for multi-host collective events.

--- a/packages/core/EventManager.ts
+++ b/packages/core/EventManager.ts
@@ -114,9 +114,13 @@ export default class EventManager {
     }
 
-    // Fallback to Cal Video if Google Meet is selected w/o a Google Cal
-    // @NOTE: destinationCalendar it's an array now so as a fallback we will only check the first one
+    // Fallback to Cal Video if Google Meet is selected without a Google Calendar integration.
+    // We intentionally check only the first (main host) destination calendar here because Google
+    // Meet availability is gated on the organizer's credential — if the primary host lacks a
+    // google_calendar integration, no Google Meet link can be generated regardless of co-host
+    // calendars. This is the correct behavior for collective/multi-host events: the organizer's
+    // calendar is authoritative for video-conferencing fallback decisions.
+    // If the array is empty (no destination calendar configured), treat it as non-Google and fall back.
     const [mainHostDestinationCalendar] = evt.destinationCalendar ?? [];
-    if (evt.location === MeetLocationType && mainHostDestinationCalendar.integration !== "google_calendar") {
+    if (
+      evt.location === MeetLocationType &&
+      mainHostDestinationCalendar?.integration !== "google_calendar"
+    ) {
       evt["location"] = "integrations:daily";
     }

🤖 Grapple PR auto-fix • minor • Review this diff before applying

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.

2 participants