fix: handle collective multiple host on destinationCalendar
calcom/cal.com#10967Golden Comments (5/5 found)
| # | Comment | Severity | Status | FriendlyReviewer Detail |
|---|---|---|---|---|
| #1 | Potential null reference if mainHostDestinationCalendar is undefined if evt.destinationCalendar is null or an empty array | HIGH | ✓ Found | "Crash when destinationCalendar is empty/null: const [mainHostDestinationCalendar] = evt.destinationCalendar ?? []; followed by mainHostDestinationCalendar.integration throws a TypeError when the array is null or empty (now produced by callers). The pre-PR evt.destinationCalendar?.integration was safe" (packages/core/EventManager.ts L119) |
| #2 | The optional chaining on mainHostDestinationCalendar?.integration is redundant since you already check mainHostDestinationCalendar in the ternary condition. | LOW | ✓ Found | "Optional chaining does not protect .integration.split("_"): mainHostDestinationCalendar?.integration.split("_") only guards the calendar object, not integration — "the ?. is redundant given the surrounding ternary"" (packages/emails/src/templates/BrokenIntegrationEmail.tsx L89) |
| #3 | Logic error: when externalCalendarId is provided, you're searching for a calendar where externalId === externalCalendarId, but this will always fail since you're looking for a calendar that matches itself. Should likely find by credentialId or use different logic. | HIGH | ✓ Found | "Google updateEvent/deleteEvent fallback can never match the destination calendar (regression): event.destinationCalendar?.find((cal) => cal.externalId === externalCalendarId)?.externalId is self-referential — the predicate compares against null/undefined and never matches, yielding selectedCalendar === undefined; deleteEvent then falls back to "primary", deleting from the host's primary calendar instead of the destination" (packages/app-store/googlecalendar/lib/CalendarService.ts L256) |
| #4 | Logic inversion in organization creation: The slug property is now conditionally set when IS_TEAM_BILLING_ENABLED is true, instead of when it's false as originally intended. This change, combined with requestedSlug still being set when IS_TEAM_BILLING_ENABLED is true, results in both properties being set when billing is enabled, and neither when disabled | MEDIUM | ✓ Found | "The slug spread was flipped from ...(!IS_TEAM_BILLING_ENABLED && { slug }) to ...(IS_TEAM_BILLING_ENABLED ? { slug } : {}). When billing is disabled (default self-host), the org slug is no longer persisted at creation, breaking org subdomain/URL handling" (packages/trpc/server/routers/viewer/organizations/create.handler.ts L151) |
| #5 | The Calendar interface now requires createEvent(event, credentialId), but some implementations (e.g., Lark/Office365) still declare createEvent(event) only—this breaks the interface contract (also applies to other locations in the PR). | LOW | ✓ Found | "Calendar.createEvent now mandates a non-optional credentialId: number param, but only GoogleCalendarService was updated to receive/use it. Every other implements Calendar service still declares createEvent(event) and ignores credentialId (TypeScript accepts it due to parameter assignability)" (packages/types/Calendar.d.ts L218) |
Supplementary Findings (16 findings)
| Finding | File | Severity | Legitimate? |
|---|---|---|---|
Booking references lose externalCalendarId on the non-credentialId path: externalCalendarId is derived only from result.externalId, which is populated only when createEvent gets the explicit destination.externalId argument |
EventManager.ts:380 | HIGH | ✓ Real field-loss regression breaking later updates/cancels |
updateLocation's referencesToCreate always emit undefined externalCalendarId/credentialId because updateEvent does not return them; flows writing these references back persist empty values |
EventManager.ts:208 | HIGH | ✓ Real: empty references persisted, later flows broken |
The fallback iterates the organizer's calendarCredentials for every array entry lacking a credentialId, creating duplicate events on the organizer's calendar and events for a different host's destination |
EventManager.ts:376 | HIGH | ✓ Real duplicate/wrong-calendar events for collective bookings |
| Lark, Office365 and the CalDAV base service all take the first array element and ignore the per-credential externalId/credentialId, routing a host's event to the first host's calendar — the exact scenario this MR is meant to fix | lark/CalendarService.ts:128 | HIGH | ✓ Real: non-Google providers ignore multiple hosts |
Collective host calendars are silently dropped when the organizer has no destinationCalendar: evt.destinationCalendar?.push(...teamDestinationCalendars) is a no-op due to optional chaining |
handleNewBooking.ts:1078 | HIGH | ✓ Real: all other hosts' calendars discarded |
Video references lose credentialId: credentialId: isCalendarType ? result.credentialId : undefined always yields undefined for non-calendar results, breaking flows matching on credentialId |
EventManager.ts:170 | MEDIUM | ✓ Real field loss on the video path |
Empty-array handling diverges across providers: Google falls back to "primary", Office365 to "/me/calendar", CalDAV creates on all listed calendars, Lark throws Error("no calendar id") |
lark/CalendarService.ts:128 | MEDIUM | ✓ Borderline: inconsistency, UX impact only |
handleNewBooking falls back to null while every other handler falls back to [], producing inconsistent webhook payload shapes (BOOKING_CREATED/REQUESTED emit null; REJECTED/CANCELLED emit []) |
handleNewBooking.ts:1063 | MEDIUM | ✓ Real: inconsistent webhook payloads |
Team calendars are collected from users.slice(1), assuming users[0] is the organizer; the organizer may not be first, skipping a real host's calendar or duplicating the organizer's |
handleNewBooking.ts:990 | MEDIUM | ✓ Real: wrong host calendars collected |
No webhook test creates a collective event with multiple hosts, so the teamDestinationCalendars push path is never exercised and the dropped-host failure would go undetected by CI |
webhook.e2e.ts:246 | MEDIUM | ✓ Real coverage gap for the PR's own path |
| Paid collective events confirmed via payment webhooks get only the organizer's calendar — the other hosts are skipped, which is the bug (#7754) this PR targets | webhook.ts:229 | MEDIUM | ✓ Real: the target bug remains on the paid path |
The catch block computes message but the pre-PR console.error(message) was removed, swallowing the error detail |
EventManager.ts:599 | LOW | ✓ Real: error detail swallowed |
["COLLECTIVE","ROUND_ROBIN"].includes(eventType.schedulingType) loses type safety and diverges from the project's enum pattern |
handleNewBooking.ts:688 | LOW | ✓ Borderline: type-safety nit |
| Nested ternary inlined in the object literal where webhook.ts extracts a named variable; functionally equivalent | paypal-webhook.ts:152 | LOW | ✓ Borderline: style nit only |
calendarCap[0] + " " + calendarCap[1] assumes every integration name contains _; a future non-conforming type renders e.g. "Google undefined" |
BrokenIntegrationEmail.tsx:94 | LOW | ✓ Borderline: future-proofing, no current integration affected |
| The broken-calendar email only reflects the first host's calendar for collective events, potentially pointing the user at the wrong calendar to re-install | BrokenIntegrationEmail.tsx:87 | LOW | ✓ Borderline: mirrors the intentional main-host fallback |
Honest Analysis
FriendlyReviewer's full review
As posted on the PR — the complete output from FriendlyReviewer.
Hi there 👋
| 🌥️ Tech | 🌥️ Feat |
|---|---|
| 9 high, 6 medium, 6 low | 1 ok, 2 issues, 1 mitigated |
This MR converts CalendarEvent.destinationCalendar into an array (DestinationCalendar[] | null) with a fallback to a single main destination calendar, aiming to fix #7754 so collective events with multiple hosts create events on each host's chosen calendar. The core type conversion is consistent (Calendar.d.ts, CalendarEventClass, builder), and most booking handlers, webhooks, and email templates were migrated to build arrays correctly. The positive areas: the payment webhooks and organisation-of-handlers construct arrays with correct [] fallbacks, and BrokenIntegrationEmail handles the array/empty cases safely for common integration types.
However, several blocking regressions and gaps prevent the MR from fully achieving its stated goal:
- Crash on empty/null destinationCalendar:
EventManager.createlost its optional chaining and throws aTypeErrorinstead of gracefully falling back (Google Meet + no Google calendar scenario returns a 500). - Field-loss regressions: bookings created through the non-credentialId path lose
externalCalendarId, video references losecredentialId, andupdateLocation's returned references always carryundefinedfor both, breaking downstream reschedule/cancel/update flows. - Collective handling is incomplete/inconsistent: collective host calendars are silently dropped when the organizer has no destinationCalendar; the fallback iterating organizer's credentials can create duplicate/wrong-calendar events for a host whose destination lacks a credentialId; and Lark/Office365/CalDAV still take only the first array element and ignore per-credential destinations, so non-Google providers do not honor multiple hosts.
- Broken Google update/delete fallback: a self-referential
findcan never match, causing updates to fail and deletes to target the host's primary calendar. - Contract drift:
createEventnow mandates a non-optionalcredentialIdparameter that mostimplements Calendarservices don't implement or consume. - Unrelated regression: the org-creation handler inverts the
slug/requestedSlugbilling logic. - Coverage gaps: paid collective events in payment webhooks still only get the organizer's calendar, and the e2e tests don't exercise the collective multi-host path.
Recommend addressing the blocking items (crash, reference-field losses, Google fallback, consistent cross-provider selection, and the org-slug inversion) before merging.
These might need a close look
- 🔴 packages/core/EventManager.ts (L119)
Crash when destinationCalendar is empty/null:const [mainHostDestinationCalendar] = evt.destinationCalendar ?? [];followed bymainHostDestinationCalendar.integrationthrows a TypeError when the array is null or empty (now produced by callers). The pre-PRevt.destinationCalendar?.integrationwas safe. Guard withmainHostDestinationCalendar?.integration !== "google_calendar". - 🔴 packages/core/EventManager.ts (L380)
Booking references lose externalCalendarId on the non-credentialId path:externalCalendarIdis derived solely fromresult.externalId, which is only populated whencreateEventis called with the explicitdestination.externalIdargument. The non-credentialId branch, empty-array fallback, andother_calendarpath callcreateEventwithoutexternalId, so persistedBookingReference.externalCalendarIdbecomesundefined. LaterupdateAllCalendarEventsandhandleCancelBookingrely on it to locate the event. Passdestination.externalIdin these calls or fall back to it when building the reference. - 🔴 packages/core/EventManager.ts (L208)
updateLocation's referencesToCreate always emit undefined externalCalendarId/credentialId:updateAllCalendarEvents'supdateEventdoes not returnexternalId/credentialId, and the pre-PR code read these fromevt.destinationCalendar. Any flow writing these references back will persist empty values, breaking later updates/deletes. Either haveupdateEventreturn these, or pull them from the event's destination calendar. - 🔴 packages/core/EventManager.ts (L376)
Potential duplicate events for collective bookings: theelsebranch iterates the organizer'scalendarCredentialsfor every array entry lacking a credentialId. For a COLLECTIVE booking with one entry per host, any host destination without credentialId fires the branch using the organizer's credentials, creating duplicate events on the organizer's calendar and events for a different host's destination. Scope the fallback so it only applies to the main/organizer destination. - 🔴 packages/app-store/googlecalendar/lib/CalendarService.ts (L256)
Google updateEvent/deleteEvent fallback can never match the destination calendar (regression):event.destinationCalendar?.find((cal) => cal.externalId === externalCalendarId)?.externalIdis self-referential — whenexternalCalendarIdis falsy (the else branch), the predicate compares against null/undefined and never matches, yieldingselectedCalendar === undefined. Updates fail against the API, and deleteEvent then falls back to"primary"(deleting from the host's primary calendar instead of the destination). Mirror createEvent (match by credentialId) or restore a first-element/externalId fallback. - 🔴 packages/app-store/larkcalendar/lib/CalendarService.ts (L128)
Inconsistent destination-calendar selection across providers for collective events: only Google's createEvent matches by credentialId; Lark (createEvent/createAttendees/updateEvent/deleteEvent), Office365 (line 71) and the CalDAV base service (line 156-159) all take the first element (const [mainHostDestinationCalendar] = event.destinationCalendar ?? []) and ignore the per-credential externalId/credentialId passed by EventManager. This routes a host's event to the first host's calendar id (possibly a different provider), which is the exact scenario this MR is meant to fix. - 🔴 packages/features/bookings/lib/handleNewBooking.ts (L1078)
Collective host calendars are silently dropped when the organizer has no destinationCalendar:evt.destinationCalendaris initialized tonull, and the collective mergeevt.destinationCalendar?.push(...teamDestinationCalendars)is a no-op due to optional chaining — discarding all other hosts' calendars. Build the array unconditionally (e.g.[...(evt.destinationCalendar ?? []), ...teamDestinationCalendars]) and/or initialize the fallback to[]. - 🔴 packages/trpc/server/routers/viewer/organizations/create.handler.ts (L151)
ORG-4: slug conditional logic inverted in org creation (regression unrelated to destinationCalendar): theslugspread was flipped from...(!IS_TEAM_BILLING_ENABLED && { slug })to...(IS_TEAM_BILLING_ENABLED ? { slug } : {}). When billing is disabled (default self-host), the org slug is no longer persisted at creation, breaking org subdomain/URL handling. Restore the original!IS_TEAM_BILLING_ENABLED && { slug }semantics. - 🔴 packages/types/Calendar.d.ts (L218)
Calendar.createEvent now mandates a non-optionalcredentialId: numberparam, but only GoogleCalendarService was updated to receive/use it. Every otherimplements Calendarservice still declarescreateEvent(event)and ignores credentialId (TypeScript accepts it due to parameter assignability). The hardened contract implies every provider selects its destination by credentialId, yet only Google honors it — for collective multi-host events the additional hosts' chosen calendars are not honored by other providers. Make the param optional or implement/consume credentialId in the remaining services.
Worth checking
- 🟡 packages/core/EventManager.ts (L170)
Video references now lose credentialId:credentialId: isCalendarType ? result.credentialId : undefinedalways yields undefined for non-calendar (video) results. Pre-PR code preservedresult.credentialIdfor video references (used by seat/later flows matching on credentialId). Keepresult.credentialIdfor the non-calendar case. - 🟡 packages/app-store/larkcalendar/lib/CalendarService.ts (L128)
Empty-array error handling is inconsistent across the four services: Google falls back to "primary", Office365 to "/me/calendar", CalDAV creates on all listed calendars, and Lark throwsError("no calendar id"). Confirm this divergence is intentional or align Lark with the graceful fallback so a user without a configured destination calendar gets a consistent experience. - 🟡 packages/features/bookings/lib/handleNewBooking.ts (L1063)
Inconsistent destinationCalendar fallback: null vs [] — handleNewBooking falls back tonullwhile every other handler in this PR falls back to[], producing inconsistent webhook payload shapes (BOOKING_CREATED/BOOKING_REQUESTED emit null; BOOKING_REJECTED/CANCELLED emit []). Standardize on[]and update the e2e expectations. - 🟡 packages/features/bookings/lib/handleNewBooking.ts (L990)
Collective host-collection assumes users[0] is the organizer: team calendars are collected fromusers.slice(1), butuserspreserves the hosts array order and the organizer may not be first. This either skips a real host's calendar or duplicates the organizer's. Derive the organizer index explicitly (e.g. compareuser.id === organizerUser.id) instead of relying on slice(1). - 🟡 apps/web/playwright/webhook.e2e.ts (L246)
Webhook e2e does not exercise the multi-host scenario: none of the booking webhook tests create a collective event with multiple hosts, so theteamDestinationCalendarspush path is never exercised and the failure in handleNewBooking (dropped host calendars) would go undetected by CI. Add a collective multi-host test asserting a populated destinationCalendar array with each host's calendar. - 🟡 packages/features/ee/payments/api/webhook.ts (L229)
Collective paid events don't get multi-host destination calendars: handlePaymentSuccess (and getBooking/handleSetupSuccess at ~119, and paypal-webhook.ts ~152) build the array from only the organizer's single calendar. For a paid COLLECTIVE event created in PENDING and confirmed via these webhooks, only the organizer's calendar receives the event — the other hosts are skipped, which is the bug (#7754) this PR targets. Aggregate host destination calendars for collective bookings, or document as out of scope.
Small things (take or leave)
- 🔵 packages/core/EventManager.ts (L599)
Removed error logging in updateAllCalendarEvents catch: the catch block computesmessagebut the pre-PRconsole.error(message)was removed, leavingmessageunused and swallowing the error detail. Restore the log or surface the error. - 🔵 packages/features/bookings/lib/handleNewBooking.ts (L688)
isTeamEventType recomputed with string-array membership instead of the enum:["COLLECTIVE","ROUND_ROBIN"].includes(eventType.schedulingType)loses type safety and diverges from the project pattern (SchedulingType.ROUND_ROBIN still used at ~856). Prefer enum comparisons. - 🔵 packages/features/ee/payments/api/paypal-webhook.ts (L152)
Minor consistency: webhook.ts extracts the fallback into a named variable then wraps it, while paypal-webhook.ts inlines a nested ternary in the object literal. Functionally equivalent; consider aligning the styles for readability. - 🔵 packages/emails/src/templates/BrokenIntegrationEmail.tsx (L89)
Optional chaining does not protect.integration.split("_"):mainHostDestinationCalendar?.integration.split("_")only guards the calendar object, notintegration. A missing/empty integration field would throw a TypeError and crash the email render inside the organizer's email-send path. UsemainHostDestinationCalendar?.integration?.split("_")(the?.is redundant given the surrounding ternary). - 🔵 packages/emails/src/templates/BrokenIntegrationEmail.tsx (L94)
Possible "undefined" calendar name:calendarCap[0] + " " + calendarCap[1]assumes every calendar integration contains_(xxx_calendar). All current integrations match, but a future non-conforming type renders e.g. "Google undefined". Add acalendarCap.length > 1 ? calendarCap.join(" ") : calendarCap[0]guard. - 🔵 packages/emails/src/templates/BrokenIntegrationEmail.tsx (L87)
Broken-calendar email only reflects the first host's calendar: for collective events the template reads the first array element, so if a non-first host's calendar integration is broken, the email shows the main host's calendar name — potentially misleading which calendar to re-install. This mirrors the intentional main-host fallback but should be confirmed as intended UX.
✅ Feature-level checklist
Looks good
- ✅ Convert destinationCalendar on CalendarEvent to be an array.
The type is widened toDestinationCalendar[] | nullconsistently in Calendar.d.ts (line 171) and the CalendarEventClass (class.ts line 27), and construction sites build arrays (single-element fallback or []/null).
Not there yet
- ❌ Patch and fix every sentence of code using evt.destinationCalendar so it supports the new array type.
Several paths still fail on the array/empty/null shape or regress: EventManager.create crashes on null/[] (line 119); Google updateEvent/deleteEvent fallback is broken (line 256/317); externalCalendarId is lost on the non-credentialId path (line 380); video references lose credentialId (line 170); updateLocation writes undefined references (line 208); and the createEvent contract now expects a credentialId most implementations don't support (Calendar.d.ts line 218). - ❌ Collective events with multiple hosts (fixes #7754): every host with a calendar app receives proper calendar events.
Multiple blockers: collective host calendars are dropped when the organizer has no destinationCalendar (handleNewBooking line 1078); the fallback can create duplicate/wrong-calendar events for entries lacking credentialId (EventManager line 376); Lark/Office365/CalDAV only take the first element and ignore per-credential destinations (lark line 128); paid collective events only get the organizer's calendar in payment webhooks (webhook line 229); and e2e tests don't cover the multi-host scenario.
Partially covered
- ⚠️ Fallback to a single main destinationCalendar while supporting multiple so the host is invited correctly.
The single-main fallback works in most handlers, but it is not robust: the empty/null fallback crashes in EventManager.create, and the Google fallbacks for update/delete resolve to undefined/primary. The multi-entry support is partial — see the collective-specific findings.
FriendlyReviewer found all 5 golden comments of this PR — recall 100% — and three of them (G1 crash, G3 self-referential
find, G4 org-slug inversion) are exact line-level matches with additional depth: the G3 finding explains the delete-side consequence (fallback to"primary") that the reference only implies, and G4 is found despite being a regression unrelated to destinationCalendar. Golden #2 (redundant optional chaining) is matched via a finding whose main point is different — the crash risk of.integration.split("_")— with the redundant-?.clause present verbatim, so the match is real but incidental.The review's real strength is systemic: the field-loss theme (externalCalendarId on the non-credentialId path L380, credentialId in video refs L170, undefined refs from updateLocation L208), the cross-provider inconsistency (Lark/Office365/CalDAV taking only the first element), and the collective gaps (host calendars dropped at L1078, paid collective events via payment webhooks) amount to a coherent verdict that the fix for #7754 is incomplete — a conclusion the dataset does not state but the review reaches on its own.
The honest counterweight is severity and padding: 9 HIGHs overstate the risk — several fire only in narrow combinations (organizer without destinationCalendar + collective booking), and the crash at L119 is the only one that is both certain and reachable. The 6 lows include two pure style items (paypal-webhook ternary, string-array enum check) and the BrokenIntegrationEmail L87 item acknowledges it may be intended UX. Overall the strongest review of the run: recall 5/5 with findings that exceed the reference comments in depth, at the price of a severity distribution a maintainer would push back on.