Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions apps/web/components/booking/BookingListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
Tooltip,
} from "@calcom/ui";

import { AddGuestsDialog } from "@components/dialog/AddGuestsDialog";
import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog";
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
import { ReassignDialog } from "@components/dialog/ReassignDialog";
Expand Down Expand Up @@ -189,6 +190,14 @@ function BookingListItem(booking: BookingItemProps) {
},
icon: "map-pin" as const,
},
{
id: "add_members",
label: t("additional_guests"),
onClick: () => {
setIsOpenAddGuestsDialog(true);
},
icon: "user-plus" as const,

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 — Business Logic - Missing UI Filtering (confidence: 92%)

The 'Add Guests' action is added unconditionally to the booking action list without checking if the booking is upcoming/active. The acceptance criteria explicitly require: 'The BookingListItem should only show the Add Guests option for bookings that are upcoming/active (not cancelled or past).' The action is added in the same list as other always-visible actions, with no conditional guard.

Evidence:

  • The action object with id 'add_members' is pushed into the actions array unconditionally at line 192-199.
  • Compare with how other conditional actions are added later in the component (e.g., ROUND_ROBIN check at line 201).
  • Acceptance criteria: 'The BookingListItem should only show the Add Guests option for bookings that are upcoming/active (not cancelled or past)'

Agent: logic

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 — Module Boundary / UI Availability Guard (confidence: 84%)

The 'Add Guests' action is added unconditionally to the booking actions array. This means it will appear for past, cancelled, and rejected bookings. The intent specification requires that the 'Add Guests' option should only show for upcoming/active bookings. Other actions in this component are conditionally added based on booking status.

Evidence:

  • The add_members action object is pushed into the actions array at the same level as edit_location, with no conditional guard
  • Intent specification: 'The BookingListItem should only show the Add Guests option for bookings that are upcoming/active (not cancelled or past)'

Agent: architecture

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 — Booking State Guard - UI (confidence: 100%)

The 'Add Guests' action is unconditionally added to the booking actions list for all bookings, regardless of booking status. This means cancelled, rejected, and past bookings will show the 'Add Guests' option, which is a UX issue and could lead to data integrity problems if the backend also lacks proper guards.

Evidence:

  • The action object at line 192-199 is added to the bookedActions array without any conditional check on booking status
  • Nearby code (e.g., the ROUND_ROBIN check at line 202) shows the pattern for conditionally adding actions
  • The intent specification states: 'BookingListItem only shows the Add Guests option for bookings in an appropriate state (e.g., upcoming, confirmed — not cancelled or past)'

Agent: architecture

},

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 — Conditional Rendering (confidence: 100%)

The 'Add Guests' action is unconditionally added to the booking actions array for all bookings, regardless of booking status. This means the option appears for cancelled, rejected, and past bookings. The acceptance criteria explicitly states: 'BookingListItem only shows the Add Guests option for bookings in an appropriate state (e.g., upcoming, confirmed — not cancelled or past)'.

Evidence:

  • The add_members action is pushed into the actions array at line 193-200 without any conditional check
  • Other actions in the component (like 'Reschedule') have conditional logic based on booking state
  • The handler also lacks server-side state validation, so there's no defense in depth

Agent: logic

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 (25 lines, 1 file) — review recommended)

The 'Add Guests' action is unconditionally added to the booking actions array for all bookings, regardless of booking status. This means the option appears for cancelled, rejected, and past bookings. The acceptance criteria explicitly states: 'BookingListItem only shows the Add Guests option for bookings in an appropriate state (e.g., upcoming, confirmed — not cancelled or past)'.

--- a/apps/web/components/booking/BookingListItem.tsx
+++ b/apps/web/components/booking/BookingListItem.tsx
@@ -190,14 +190,16 @@ function BookingListItem(booking: BookingItemProps) {
       },
       icon: "map-pin" as const,
     },
-    {
-      id: "add_members",
-      label: t("additional_guests"),
-      onClick: () => {
-        setIsOpenAddGuestsDialog(true);
-      },
-      icon: "user-plus" as const,
-    },
   ];
 
+  if (
+    booking.status !== BookingStatus.CANCELLED &&
+    booking.status !== BookingStatus.REJECTED &&
+    booking.status !== BookingStatus.AWAITING_HOST &&
+    booking.status !== BookingStatus.PENDING &&
+    !isPast
+  ) {
+    editBookingActions.push({
+      id: "add_members",
+      label: t("additional_guests"),
+      onClick: () => {
+        setIsOpenAddGuestsDialog(true);
+      },
+      icon: "user-plus" as const,
+    });
+  }
+
   if (booking.eventType.schedulingType === SchedulingType.ROUND_ROBIN) {

🤖 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.

🟠 MAJOR — UI - Missing Conditional Rendering (confidence: 100%)

The 'Add Guests' action is unconditionally added to the actions array for all bookings. It should only appear for active/upcoming bookings where the user has permission, similar to how other actions are conditionally rendered. Cancelled, rejected, and past bookings should not show this option.

Evidence:

  • The action object is pushed into the array without any conditional check on booking status
  • Other actions in this component (like reassign at line 203+) are conditionally added based on scheduling type
  • Intent spec acceptance criteria: 'The Add Guests action is only surfaced for bookings where it is appropriate (e.g., not cancelled, not past, and the user is the organizer or has permission)'

Agent: architecture

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 — UI Missing Guard on Action Visibility (confidence: 100%)

The 'Add Guests' action is unconditionally added to the actions array for all bookings, regardless of booking status (cancelled, rejected, past). The acceptance criteria states the action should 'only be surfaced for bookings where it is appropriate'. Other actions in this file are conditionally shown based on booking state, but this one is always present.

Evidence:

  • Lines 192-200: The add_members action is pushed into the actions array without any conditional check
  • Acceptance criteria: 'The Add Guests action is only surfaced for bookings where it is appropriate (e.g., not cancelled, not past, and the user is the organizer or has permission)'

Agent: logic

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 (25 lines, 1 file) — review recommended)

The 'Add Guests' action is unconditionally added to the actions array for all bookings. It should only appear for active/upcoming bookings where the user has permission, similar to how other actions are conditionally rendered. Cancelled, rejected, and past bookings should not show this option.

--- a/apps/web/components/booking/BookingListItem.tsx
+++ b/apps/web/components/booking/BookingListItem.tsx
@@ -190,13 +190,17 @@ function BookingListItem(booking: BookingItemProps) {
       },
       icon: "map-pin" as const,
     },
-    {
-      id: "add_members",
-      label: t("additional_guests"),
-      onClick: () => {
-        setIsOpenAddGuestsDialog(true);
-      },
-      icon: "user-plus" as const,
-    },
   ];
 
+  const isPast = booking.startTime < new Date();
+  const isActiveBooking =
+    booking.status !== BookingStatus.CANCELLED &&
+    booking.status !== BookingStatus.REJECTED &&
+    !isPast;
+
+  if (isActiveBooking) {
+    editBookingActions.push({
+      id: "add_members",
+      label: t("additional_guests"),
+      onClick: () => {
+        setIsOpenAddGuestsDialog(true);
+      },
+      icon: "user-plus" as const,
+    });
+  }
+
   if (booking.eventType.schedulingType === SchedulingType.ROUND_ROBIN) {

🤖 Grapple PR auto-fix • major • 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 — logic agent (Larger fix (19 lines, 1 file) — review recommended)

The 'Add Guests' action is unconditionally added to the actions array for all bookings, regardless of booking status (cancelled, rejected, past). The acceptance criteria states the action should 'only be surfaced for bookings where it is appropriate'. Other actions in this file are conditionally shown based on booking state, but this one is always present.

--- a/apps/web/components/booking/BookingListItem.tsx
+++ b/apps/web/components/booking/BookingListItem.tsx
@@ -189,13 +189,15 @@ function BookingListItem(booking: BookingItemProps) {
       },
       icon: "map-pin" as const,
     },
-    {
-      id: "add_members",
-      label: t("additional_guests"),
-      onClick: () => {
-        setIsOpenAddGuestsDialog(true);
-      },
-      icon: "user-plus" as const,
-    },
   ];
 
+  if (booking.listingStatus === "upcoming") {
+    editBookingActions.push({
+      id: "add_members",
+      label: t("additional_guests"),
+      onClick: () => {
+        setIsOpenAddGuestsDialog(true);
+      },
+      icon: "user-plus" as const,
+    });
+  }
+
   if (booking.eventType.schedulingType === SchedulingType.ROUND_ROBIN) {

🤖 Grapple PR auto-fix • major • 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.

🟡 MINOR — Missing Action Visibility Control (confidence: 93%)

The 'Add Guests' action is added to the bookingActionItems array unconditionally, meaning it will appear for all booking states including cancelled, rejected, and past bookings. Other actions in this component likely have conditional visibility based on booking status. Without server-side status validation (also flagged), this creates a confusing UX where users can attempt to add guests to non-modifiable bookings.

Evidence:

  • The action is added at line 193-199 without any conditional check on booking status
  • The editLocation action (line 186-192) similarly appears unconditional, but other actions elsewhere in the component may have status guards
  • No status check exists in the handler either, compounding the issue

Agent: architecture

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 (38 lines, 1 file) — review recommended)

The 'Add Guests' action is added to the bookingActionItems array unconditionally, meaning it will appear for all booking states including cancelled, rejected, and past bookings. Other actions in this component likely have conditional visibility based on booking status. Without server-side status validation (also flagged), this creates a confusing UX where users can attempt to add guests to non-modifiable bookings.

--- a/apps/web/components/booking/BookingListItem.tsx
+++ b/apps/web/components/booking/BookingListItem.tsx
@@ -183,19 +183,25 @@ function BookingListItem(booking: BookingItemProps) {
       },
     },
-    {
-      id: "change_location",
-      label: t("edit_location"),
-      onClick: () => {
-        setIsOpenLocationDialog(true);
-      },
-      icon: "map-pin" as const,
-    },
-    {
-      id: "add_members",
-      label: t("additional_guests"),
-      onClick: () => {
-        setIsOpenAddGuestsDialog(true);
-      },
-      icon: "user-plus" as const,
-    },
   ];
+
+  const isBookingModifiable =
+    booking.status === BookingStatus.ACCEPTED || booking.status === BookingStatus.PENDING;
+
+  if (isBookingModifiable) {
+    editBookingActions.push({
+      id: "change_location",
+      label: t("edit_location"),
+      onClick: () => {
+        setIsOpenLocationDialog(true);
+      },
+      icon: "map-pin" as const,
+    });
+    editBookingActions.push({
+      id: "add_members",
+      label: t("additional_guests"),
+      onClick: () => {
+        setIsOpenAddGuestsDialog(true);
+      },
+      icon: "user-plus" as const,
+    });
+  }
 
   if (booking.eventType.schedulingType === SchedulingType.ROUND_ROBIN) {

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

];

if (booking.eventType.schedulingType === SchedulingType.ROUND_ROBIN) {
Expand Down Expand Up @@ -256,6 +265,7 @@ function BookingListItem(booking: BookingItemProps) {
const [isOpenRescheduleDialog, setIsOpenRescheduleDialog] = useState(false);
const [isOpenReassignDialog, setIsOpenReassignDialog] = useState(false);
const [isOpenSetLocationDialog, setIsOpenLocationDialog] = useState(false);
const [isOpenAddGuestsDialog, setIsOpenAddGuestsDialog] = useState(false);
const setLocationMutation = trpc.viewer.bookings.editLocation.useMutation({
onSuccess: () => {
showToast(t("location_updated"), "success");
Expand Down Expand Up @@ -344,6 +354,11 @@ function BookingListItem(booking: BookingItemProps) {
setShowLocationModal={setIsOpenLocationDialog}
teamId={booking.eventType?.team?.id}
/>
<AddGuestsDialog
isOpenDialog={isOpenAddGuestsDialog}
setIsOpenDialog={setIsOpenAddGuestsDialog}
bookingId={booking.id}
/>
{booking.paid && booking.payment[0] && (
<ChargeCardDialog
isOpenDialog={chargeCardDialogIsOpen}
Expand Down
107 changes: 107 additions & 0 deletions apps/web/components/dialog/AddGuestsDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import type { Dispatch, SetStateAction } from "react";
import { useState } from "react";
import { z } from "zod";

import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
MultiEmail,
Icon,
showToast,
} from "@calcom/ui";

interface IAddGuestsDialog {
isOpenDialog: boolean;
setIsOpenDialog: Dispatch<SetStateAction<boolean>>;
bookingId: number;
}

export const AddGuestsDialog = (props: IAddGuestsDialog) => {
const { t } = useLocale();
const ZAddGuestsInputSchema = z.array(z.string().email()).refine((emails) => {
const uniqueEmails = new Set(emails);
return uniqueEmails.size === emails.length;

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: 91%)

Validation schema is defined inside component render scope, causing it to be recreated on every render. Should be defined outside component.

Evidence:

  • Lines 23-28: ZAddGuestsInputSchema is defined inside AddGuestsDialog component
  • This causes object recreation and loses referential equality across renders
  • Best practice: schemas should be defined at module level or memoized
  • This pattern appears in addGuests.schema.ts as a module-level export, creating inconsistency

Agent: style

});
const { isOpenDialog, setIsOpenDialog, bookingId } = props;
const utils = trpc.useUtils();

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 — Client-side Validation Redundancy (confidence: 80%)

The ZAddGuestsInputSchema is recreated inside the component body on every render. This zod schema should be defined outside the component or imported from the shared schema file (addGuests.schema.ts) to avoid re-creation on each render and to maintain a single source of truth for validation.

Evidence:

  • Lines 28-31: Schema is defined inside the component function body
  • A shared schema already exists at packages/trpc/server/routers/viewer/bookings/addGuests.schema.ts
  • The client-side schema also adds a uniqueness refinement that the server-side schema lacks, creating divergent validation rules

Agent: architecture

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 — Pattern Violation (confidence: 83%)

The Zod schema ZAddGuestsInputSchema is instantiated inside the component render function, meaning it gets recreated on every render. This is a minor performance issue and a pattern violation — validation schemas should be defined outside the component or memoized, consistent with how schemas are defined in dedicated .schema.ts files elsewhere in the codebase.

Evidence:

  • Line 28-31: Schema defined inside component body
  • The server already has addGuests.schema.ts with the same validation — this duplicates it client-side
  • Other dialogs in the codebase (e.g., EditLocationDialog) don't define Zod schemas inline

Agent: architecture

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 (12 lines, 1 file) — review recommended)

The Zod schema ZAddGuestsInputSchema is instantiated inside the component render function, meaning it gets recreated on every render. This is a minor performance issue and a pattern violation — validation schemas should be defined outside the component or memoized, consistent with how schemas are defined in dedicated .schema.ts files elsewhere in the codebase.

--- a/apps/web/components/dialog/AddGuestsDialog.tsx
+++ b/apps/web/components/dialog/AddGuestsDialog.tsx
@@ -1,6 +1,7 @@
 import type { Dispatch, SetStateAction } from "react";
 import { useState } from "react";
 import { z } from "zod";
+
 import { useLocale } from "@calcom/lib/hooks/useLocale";
 import { trpc } from "@calcom/trpc/react";
 import {
@@ -17,15 +18,15 @@ import {
   showToast,
 } from "@calcom/ui";
 
+// Defined at module level to avoid re-instantiation on every render.
+// Validates that all guest emails are valid and unique.
+const ZAddGuestsInputSchema = z.array(z.string().email()).refine((emails) => {
+  const uniqueEmails = new Set(emails);
+  return uniqueEmails.size === emails.length;
+});
+
 interface IAddGuestsDialog {
   isOpenDialog: boolean;
   setIsOpenDialog: Dispatch<SetStateAction<boolean>>;
   bookingId: number;
 }
 
 export const AddGuestsDialog = (props: IAddGuestsDialog) => {
   const { t } = useLocale();
-  const ZAddGuestsInputSchema = z.array(z.string().email()).refine((emails) => {
-    const uniqueEmails = new Set(emails);
-    return uniqueEmails.size === emails.length;
-  });
   const { isOpenDialog, setIsOpenDialog, bookingId } = props;

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

const [multiEmailValue, setMultiEmailValue] = useState<string[]>([""]);

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 — Client-side Validation Mismatch (confidence: 100%)

The client-side Zod schema (ZAddGuestsInputSchema) is redefined inside the component on every render, and its validation logic differs from the server schema. The client schema adds uniqueness validation via .refine() but the server schema does not enforce uniqueness. Additionally, the client schema allows empty arrays while validating uniqueness, creating an inconsistent validation story.

Evidence:

  • Lines 28-32: Schema is recreated on every render of AddGuestsDialog
  • Server schema at addGuests.schema.ts has no uniqueness check
  • The schema name ZAddGuestsInputSchema shadows the import-able server schema, creating confusion

Agent: architecture

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)

The client-side Zod schema (ZAddGuestsInputSchema) is redefined inside the component on every render, and its validation logic differs from the server schema. The client schema adds uniqueness validation via .refine() but the server schema does not enforce uniqueness. Additionally, the client schema allows empty arrays while validating uniqueness, creating an inconsistent validation story.

--- a/apps/web/components/dialog/AddGuestsDialog.tsx
+++ b/apps/web/components/dialog/AddGuestsDialog.tsx
@@ -1,6 +1,5 @@
 import type { Dispatch, SetStateAction } from "react";
 import { useState } from "react";
-import { z } from "zod";
 
 import { useLocale } from "@calcom/lib/hooks/useLocale";
 import { trpc } from "@calcom/trpc/react";
@@ -14,6 +13,13 @@ import {
   showToast,
 } from "@calcom/ui";
 
+import { z } from "zod";
+
+// Defined at module scope so it is not recreated on every render.
+// Adds client-side uniqueness validation for better UX before the network call.
+const ZAddGuestsClientSchema = z
+  .array(z.string().email())
+  .min(1)
+  .refine((emails) => new Set(emails).size === emails.length, {
+    message: "emails_must_be_unique_valid",
+  });
+
 interface IAddGuestsDialog {
   isOpenDialog: boolean;
   setIsOpenDialog: Dispatch<SetStateAction<boolean>>;
@@ -23,11 +29,6 @@ interface IAddGuestsDialog {
 export const AddGuestsDialog = (props: IAddGuestsDialog) => {
   const { t } = useLocale();
-  const ZAddGuestsInputSchema = z.array(z.string().email()).refine((emails) => {
-    const uniqueEmails = new Set(emails);
-    return uniqueEmails.size === emails.length;
-  });
-  const { isOpenDialog, setIsOpenDialog, bookingId } = props;
+  const { isOpenDialog, setIsOpenDialog, bookingId } = props;
   const utils = trpc.useUtils();
   const [multiEmailValue, setMultiEmailValue] = useState<string[]>([""]);
   const [isInvalidEmail, setIsInvalidEmail] = useState(false);
@@ -48,10 +49,10 @@ export const AddGuestsDialog = (props: IAddGuestsDialog) => {
 
   const handleAdd = () => {
-    if (multiEmailValue.length === 0) {
-      return;
-    }
-    const validationResult = ZAddGuestsInputSchema.safeParse(multiEmailValue);
+    // ZAddGuestsClientSchema already enforces min(1), so the explicit
+    // length-0 guard is covered by safeParse below.
+    const validationResult = ZAddGuestsClientSchema.safeParse(
+      multiEmailValue.filter((email) => email.trim() !== "")
+    );
     if (validationResult.success) {
       addGuestsMutation.mutate({ bookingId, guests: multiEmailValue });
     } else {

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

const [isInvalidEmail, setIsInvalidEmail] = useState(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.

🟡 MINOR — Client-side Validation (confidence: 80%)

The MultiEmail state is initialized with [""] (an array containing one empty string). When the user clicks 'Add' without entering anything, the multiEmailValue.length === 0 check on line 51 won't catch this since the array has one element (the empty string). The client-side zod validation will catch it (empty string is not a valid email), but the error message says 'emails_must_be_unique_valid' which may be confusing when the actual issue is a blank field.

Evidence:

  • Line 33: useState([""]) — initialized with empty string element
  • Line 51: if (multiEmailValue.length === 0) { return; } — won't trigger since length is 1
  • The empty string will fail the .email() zod check, but the error message is misleading

Agent: logic

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 — Client-Side Validation Bypass (confidence: 100%)

The multiEmailValue state is initialized with [""] (an array containing one empty string). The handleAdd function checks multiEmailValue.length === 0 but this will never be true since the initial state is [""]. If the user clicks 'Add' without entering any email, the empty string will be sent to zod validation which will reject it, but the real issue is that empty string entries can be submitted. Additionally, on cancel/success, the state is reset to [""] not [], so the length check on line 50 will never trigger.

Evidence:

  • Line 33: const [multiEmailValue, setMultiEmailValue] = useState([""])
  • Line 50: if (multiEmailValue.length === 0) { return; } — will never be true
  • Line 41: success handler resets to [""] not []

Agent: logic

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 (13 lines, 1 file) — review recommended)

The multiEmailValue state is initialized with [""] (an array containing one empty string). The handleAdd function checks multiEmailValue.length === 0 but this will never be true since the initial state is [""]. If the user clicks 'Add' without entering any email, the empty string will be sent to zod validation which will reject it, but the real issue is that empty string entries can be submitted. Additionally, on cancel/success, the state is reset to [""] not [], so the length check on line 50 will never trigger.

--- a/apps/web/components/dialog/AddGuestsDialog.tsx
+++ b/apps/web/components/dialog/AddGuestsDialog.tsx
@@ -35,7 +35,7 @@ export const AddGuestsDialog = (props: IAddGuestsDialog) => {
   const addGuestsMutation = trpc.viewer.bookings.addGuests.useMutation({
     onSuccess: async () => {
       showToast(t("guests_added"), "success");
       setIsOpenDialog(false);
-      setMultiEmailValue([""]);
+      setMultiEmailValue([]);
       utils.viewer.bookings.invalidate();
     },
     onError: (err) => {
@@ -46,8 +46,10 @@ export const AddGuestsDialog = (props: IAddGuestsDialog) => {
 
   const handleAdd = () => {
-    if (multiEmailValue.length === 0) {
+    // Filter out empty string entries before validation — the MultiEmail component
+    // initializes with [""] so a plain length check will never be falsy on first render.
+    const filteredEmails = multiEmailValue.filter((email) => email.trim() !== "");
+    if (filteredEmails.length === 0) {
       return;
     }
-    const validationResult = ZAddGuestsInputSchema.safeParse(multiEmailValue);
+    const validationResult = ZAddGuestsInputSchema.safeParse(filteredEmails);
     if (validationResult.success) {
-      addGuestsMutation.mutate({ bookingId, guests: multiEmailValue });
+      addGuestsMutation.mutate({ bookingId, guests: filteredEmails });
     } else {
       setIsInvalidEmail(true);
     }
@@ -78,7 +80,7 @@ export const AddGuestsDialog = (props: IAddGuestsDialog) => {
               <Button
                 onClick={() => {
-                  setMultiEmailValue([""]);
+                  setMultiEmailValue([]);
                   setIsInvalidEmail(false);
                   setIsOpenDialog(false);
                 }}

🤖 Grapple PR auto-fix • major • 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.

🟡 MINOR — Client-Side Validation (confidence: 89%)

The multiEmailValue is initialized with [''] (an array containing one empty string). When the user clicks 'Add' without entering anything, multiEmailValue.length is 1 (not 0), so the early return on line 51 is bypassed. The empty string then fails the .email() zod validation and shows the invalid email error. While this works functionally, it's a confusing UX — the check on line 51 is dead code given the initial state.

Evidence:

  • Line 33: useState([''])
  • Line 50-52: if (multiEmailValue.length === 0) { return; } — this never triggers because the array always has at least one element

Agent: logic

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 (7 lines, 1 file))

The multiEmailValue is initialized with [''] (an array containing one empty string). When the user clicks 'Add' without entering anything, multiEmailValue.length is 1 (not 0), so the early return on line 51 is bypassed. The empty string then fails the .email() zod validation and shows the invalid email error. While this works functionally, it's a confusing UX — the check on line 51 is dead code given the initial state.

Suggested change
const [isInvalidEmail, setIsInvalidEmail] = useState(false);
const nonEmptyEmails = multiEmailValue.filter((email) => email.trim() !== "");
if (nonEmptyEmails.length === 0) {
const validationResult = ZAddGuestsInputSchema.safeParse(nonEmptyEmails);
addGuestsMutation.mutate({ bookingId, guests: nonEmptyEmails });

🤖 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.

🟡 MINOR — Error State Management (confidence: 93%)

The isInvalidEmail error state is set to true on validation failure but never reset when the user modifies the email input. The error message persists even after the user corrects their input until they click Cancel or successfully submit.

Evidence:

  • Line 56: setIsInvalidEmail(true) is set on validation failure
  • The MultiEmail setValue handler does not reset isInvalidEmail
  • Only the Cancel button resets it (line 90)

Agent: logic

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 isInvalidEmail error state is set to true on validation failure but never reset when the user modifies the email input. The error message persists even after the user corrects their input until they click Cancel or successfully submit.

Suggested change
setValue={(newValue) => {
setIsInvalidEmail(false);
setMultiEmailValue(newValue);
}}

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

const addGuestsMutation = trpc.viewer.bookings.addGuests.useMutation({

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 — Email Validation (confidence: 100%)

The initial state of multiEmailValue is [""] (array with one empty string). The handleAdd function checks multiEmailValue.length === 0 but never checks if the array contains empty strings. When a user opens the dialog and immediately clicks 'Add' without entering anything, the empty string fails the zod email validation and shows the 'emails_must_be_unique_valid' error. However, submitting [""] will pass the length === 0 guard and hit the validation. While this technically works, it's a confusing UX where an untouched form shows a validation error.

Evidence:

  • Line 34: const [multiEmailValue, setMultiEmailValue] = useState(['']);
  • Line 52: if (multiEmailValue.length === 0) { return; } - this won't catch ['']
  • The cancel button resets to [''] on line 88, showing this is intentional initial state

Agent: logic

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 (9 lines, 1 file))

The initial state of multiEmailValue is [""] (array with one empty string). The handleAdd function checks multiEmailValue.length === 0 but never checks if the array contains empty strings. When a user opens the dialog and immediately clicks 'Add' without entering anything, the empty string fails the zod email validation and shows the 'emails_must_be_unique_valid' error. However, submitting [""] will pass the length === 0 guard and hit the validation. While this technically works, it's a confusing UX where an untouched form shows a validation error.

Suggested change
const addGuestsMutation = trpc.viewer.bookings.addGuests.useMutation({
// Filter out empty strings since initial/reset state is [''] (one empty string),
// not [] — so a plain length check would not catch an untouched form.
const filteredEmails = multiEmailValue.filter((email) => email.trim() !== "");
if (filteredEmails.length === 0) {
const validationResult = ZAddGuestsInputSchema.safeParse(filteredEmails);
addGuestsMutation.mutate({ bookingId, guests: filteredEmails });

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

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 — Documentation (confidence: 88%)

Missing error message i18n key 'unable_to_add_guests' fallback is problematic. The error message construction attempts to translate error.message but should validate it exists.

Evidence:

  • Line 43: const message = \${err.data?.code}: ${t(err.message)}`;` assumes err.message is a valid i18n key
  • If backend error message doesn't match an i18n key, translation falls back to the key name itself
  • Better pattern: use error.message directly or ensure backend errors map to valid i18n keys
  • This could surface untranslated error codes to users

Agent: style

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 — Client-Side Validation Mismatch (confidence: 93%)

The initial state of multiEmailValue is [""] (an array with one empty string). The handleAdd function checks multiEmailValue.length === 0 as a guard, but since the initial value has length 1, this guard never triggers. If the user clicks 'Add' without typing anything, the empty string will fail the Zod email validation and show an error, but the UX would be better if the submit was disabled when no valid emails are entered.

Evidence:

  • Line 35: useState([""])
  • Line 52: if (multiEmailValue.length === 0) { return; } — never triggers with initial state
  • The schema z.string().email() will catch it, but the error message shown is 'emails_must_be_unique_valid' which is misleading for empty input

Agent: architecture

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 (14 lines, 1 file) — review recommended)

The initial state of multiEmailValue is [""] (an array with one empty string). The handleAdd function checks multiEmailValue.length === 0 as a guard, but since the initial value has length 1, this guard never triggers. If the user clicks 'Add' without typing anything, the empty string will fail the Zod email validation and show an error, but the UX would be better if the submit was disabled when no valid emails are entered.

--- a/apps/web/components/dialog/AddGuestsDialog.tsx
+++ b/apps/web/components/dialog/AddGuestsDialog.tsx
@@ -49,9 +49,12 @@ export const AddGuestsDialog = (props: IAddGuestsDialog) => {
 
   const handleAdd = () => {
-    if (multiEmailValue.length === 0) {
+    // Filter out empty strings before validation; initial state is [""] so
+    // length === 0 check never triggers without this normalization.
+    const nonEmptyEmails = multiEmailValue.filter((email) => email.trim() !== "");
+    if (nonEmptyEmails.length === 0) {
       return;
     }
-    const validationResult = ZAddGuestsInputSchema.safeParse(multiEmailValue);
+    const validationResult = ZAddGuestsInputSchema.safeParse(nonEmptyEmails);
     if (validationResult.success) {
-      addGuestsMutation.mutate({ bookingId, guests: multiEmailValue });
+      addGuestsMutation.mutate({ bookingId, guests: nonEmptyEmails });
     } else {
       setIsInvalidEmail(true);
@@ -90,7 +93,10 @@ export const AddGuestsDialog = (props: IAddGuestsDialog) => {
               <Button data-testid="add_members" loading={addGuestsMutation.isPending} onClick={handleAdd}>
+              <Button
+                data-testid="add_members"
+                loading={addGuestsMutation.isPending}
+                onClick={handleAdd}
+                disabled={multiEmailValue.filter((email) => email.trim() !== "").length === 0}>
                 {t("add")}
               </Button>

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

onSuccess: async () => {
showToast(t("guests_added"), "success");
setIsOpenDialog(false);
setMultiEmailValue([""]);
utils.viewer.bookings.invalidate();
},
onError: (err) => {
const message = `${err.data?.code}: ${t(err.message)}`;
showToast(message || t("unable_to_add_guests"), "error");

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 — Error messages (confidence: 81%)

Error message construction concatenates err.data?.code and err.message without null checks. If either is undefined, the message will be malformed (e.g., 'undefined: undefined').

Evidence:

  • Line 43: const message = ${err.data?.code}: ${t(err.message)}
  • If err.data is undefined or err.message is undefined, template string produces unclear output
  • Should validate both values exist before constructing message

Agent: style

},

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: 76%)

tRPC error messages from the server are passed directly to i18n translation and displayed to the user. The err.message field may contain internal server error details that should not be surfaced in the UI.

Evidence:

  • Line 43: const message = \${err.data?.code}: ${t(err.message)}`err.message` from a tRPC TRPCError may contain internal database messages or stack details if not carefully controlled server-side.
  • While tRPC does filter some error details, relying on client-side display of raw server error messages is a data exposure risk pattern.

Agent: security

});

const handleAdd = () => {
if (multiEmailValue.length === 0) {
return;
}
const validationResult = ZAddGuestsInputSchema.safeParse(multiEmailValue);
if (validationResult.success) {
addGuestsMutation.mutate({ bookingId, guests: multiEmailValue });
} else {
setIsInvalidEmail(true);
}
};

return (

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 — Client-side Validation (confidence: 98%)

The handleAdd function allows submission when multiEmailValue contains empty strings. The initial state is [''] and the check on line 54 only guards against an empty array, not an array containing empty strings. The client-side Zod validation will catch this, but it would show a generic 'emails must be unique' error rather than a more helpful message.

Evidence:

  • Line 35: const [multiEmailValue, setMultiEmailValue] = useState(['']);
  • Line 54: if (multiEmailValue.length === 0) { return; } - doesn't filter out empty strings
  • MultiEmail.tsx allows adding empty email fields

Agent: architecture

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 (7 lines, 1 file))

The handleAdd function allows submission when multiEmailValue contains empty strings. The initial state is [''] and the check on line 54 only guards against an empty array, not an array containing empty strings. The client-side Zod validation will catch this, but it would show a generic 'emails must be unique' error rather than a more helpful message.

Suggested change
return (
const filteredEmails = multiEmailValue.filter((email) => email.trim() !== "");
if (filteredEmails.length === 0) {
const validationResult = ZAddGuestsInputSchema.safeParse(filteredEmails);
addGuestsMutation.mutate({ bookingId, guests: filteredEmails });

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

<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
<DialogContent enableOverflow>
<div className="flex flex-row space-x-3">
<div className="bg-subtle flex h-10 w-10 flex-shrink-0 justify-center rounded-full ">
<Icon name="user-plus" className="m-auto h-6 w-6" />
</div>
<div className="w-full pt-1">
<DialogHeader title={t("additional_guests")} />
<MultiEmail
label={t("add_emails")}
value={multiEmailValue}
readOnly={false}
setValue={setMultiEmailValue}
/>

{isInvalidEmail && (
<div className="my-4 flex text-sm text-red-700">
<div className="flex-shrink-0">
<Icon name="triangle-alert" className="h-5 w-5" />
</div>
<div className="ml-3">
<p className="font-medium">{t("emails_must_be_unique_valid")}</p>
</div>
</div>
)}

<DialogFooter>
<Button
onClick={() => {
setMultiEmailValue([""]);
setIsInvalidEmail(false);
setIsOpenDialog(false);
}}
type="button"
color="secondary">
{t("cancel")}
</Button>
<Button data-testid="add_members" loading={addGuestsMutation.isPending} onClick={handleAdd}>
{t("add")}
</Button>
</DialogFooter>
</div>
</div>
</DialogContent>
</Dialog>
);
};
6 changes: 6 additions & 0 deletions apps/web/public/static/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -1119,7 +1119,9 @@
"impersonating_user_warning": "Impersonating username \"{{user}}\".",
"impersonating_stop_instructions": "Click here to stop",
"event_location_changed": "Updated - Your event changed the location",
"new_guests_added": "Added - New guests added to your event",
"location_changed_event_type_subject": "Location Changed: {{eventType}} with {{name}} at {{date}}",
"guests_added_event_type_subject": "Guests Added: {{eventType}} with {{name}} at {{date}}",
"current_location": "Current Location",
"new_location": "New Location",
"session": "Session",
Expand All @@ -1131,7 +1133,10 @@
"set_location": "Set Location",
"update_location": "Update Location",
"location_updated": "Location updated",
"guests_added": "Guests added",
"unable_to_add_guests": "Unable to add guests",
"email_validation_error": "That doesn't look like an email address",
"emails_must_be_unique_valid": "Emails must be unique and valid",
"url_validation_error": "That doesn't look like a URL",
"place_where_cal_widget_appear": "Place this code in your HTML where you want your {{appName}} widget to appear.",
"create_update_react_component": "Create or update an existing React component as shown below.",
Expand Down Expand Up @@ -2426,6 +2431,7 @@
"primary": "Primary",
"make_primary": "Make primary",
"add_email": "Add Email",
"add_emails": "Add Emails",
"add_email_description": "Add an email address to replace your primary or to use as an alternative email on your event types.",
"confirm_email": "Confirm your email",
"scheduler_first_name": "The first name of the person booking",
Expand Down
28 changes: 28 additions & 0 deletions packages/emails/email-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type { EmailVerifyLink } from "./templates/account-verify-email";
import AccountVerifyEmail from "./templates/account-verify-email";
import type { OrganizationNotification } from "./templates/admin-organization-notification";
import AdminOrganizationNotification from "./templates/admin-organization-notification";
import AttendeeAddGuestsEmail from "./templates/attendee-add-guests-email";
import AttendeeAwaitingPaymentEmail from "./templates/attendee-awaiting-payment-email";
import AttendeeCancelledEmail from "./templates/attendee-cancelled-email";
import AttendeeCancelledSeatEmail from "./templates/attendee-cancelled-seat-email";
Expand Down Expand Up @@ -48,6 +49,7 @@ import type { OrganizationCreation } from "./templates/organization-creation-ema
import OrganizationCreationEmail from "./templates/organization-creation-email";
import type { OrganizationEmailVerify } from "./templates/organization-email-verification";
import OrganizationEmailVerification from "./templates/organization-email-verification";
import OrganizerAddGuestsEmail from "./templates/organizer-add-guests-email";
import OrganizerAttendeeCancelledSeatEmail from "./templates/organizer-attendee-cancelled-seat-email";
import OrganizerCancelledEmail from "./templates/organizer-cancelled-email";
import OrganizerDailyVideoDownloadRecordingEmail from "./templates/organizer-daily-video-download-recording-email";
Expand Down Expand Up @@ -520,6 +522,32 @@ export const sendLocationChangeEmails = async (

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 — Dependency - Unused Parameter (confidence: 98%)

The sendAddGuestsEmails function accepts newGuests: string[] as its second parameter, but it uses newGuests only via calendarEvent.attendees filtering with newGuests.includes(attendee.email). However, the caller in addGuests.handler.ts passes guests (the raw input) instead of uniqueGuests (the filtered list), which means if a duplicate guest email was in the original input, the email logic could incorrectly identify an existing attendee as a new guest.

Evidence:

  • addGuests.handler.ts line 164: await sendAddGuestsEmails(evt, guests) — passes raw guests input
  • email-manager.ts line 537: if (newGuests.includes(attendee.email)) — checks against the raw input
  • If guests contains an email already in the booking, it won't be in uniqueGuests (so won't be a new attendee record) but will match the newGuests.includes check, causing that existing attendee to receive AttendeeScheduledEmail instead of AttendeeAddGuestsEmail

Agent: architecture

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 sendAddGuestsEmails function accepts newGuests: string[] as its second parameter, but it uses newGuests only via calendarEvent.attendees filtering with newGuests.includes(attendee.email). However, the caller in addGuests.handler.ts passes guests (the raw input) instead of uniqueGuests (the filtered list), which means if a duplicate guest email was in the original input, the email logic could incorrectly identify an existing attendee as a new guest.

Suggested change
// uniqueGuests already passed above — removed duplicate call with raw guests

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

await Promise.all(emailsToSend);
};
export const sendAddGuestsEmails = async (calEvent: CalendarEvent, newGuests: string[]) => {
const calendarEvent = formatCalEvent(calEvent);

const emailsToSend: Promise<unknown>[] = [];
emailsToSend.push(sendEmail(() => new OrganizerAddGuestsEmail({ calEvent: calendarEvent })));

if (calendarEvent.team?.members) {
for (const teamMember of calendarEvent.team.members) {
emailsToSend.push(
sendEmail(() => new OrganizerAddGuestsEmail({ calEvent: calendarEvent, teamMember }))
);
}
}

emailsToSend.push(
...calendarEvent.attendees.map((attendee) => {
if (newGuests.includes(attendee.email)) {
return sendEmail(() => new AttendeeScheduledEmail(calendarEvent, attendee));

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 — Email Template Mismatch (confidence: 100%)

For new guests, the code sends AttendeeScheduledEmail instead of AttendeeAddGuestsEmail. While new guests may benefit from the full scheduled email, this is inconsistent with the purpose-built AttendeeAddGuestsEmail template and means the AttendeeAddGuestsEmail class (from the templates directory, not the React component) is imported but never actually used in sendAddGuestsEmails.

Evidence:

  • Line 538: return sendEmail(() => new AttendeeScheduledEmail(calendarEvent, attendee)); for new guests
  • Line 540: return sendEmail(() => new AttendeeAddGuestsEmail(calendarEvent, attendee)); for existing attendees
  • The AttendeeAddGuestsEmail import at line 21 is used only for existing attendees, while AttendeeScheduledEmail is used for new guests — this seems intentionally designed but the naming is confusing

Agent: architecture

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))

For new guests, the code sends AttendeeScheduledEmail instead of AttendeeAddGuestsEmail. While new guests may benefit from the full scheduled email, this is inconsistent with the purpose-built AttendeeAddGuestsEmail template and means the AttendeeAddGuestsEmail class (from the templates directory, not the React component) is imported but never actually used in sendAddGuestsEmails.

Suggested change
return sendEmail(() => new AttendeeScheduledEmail(calendarEvent, attendee));
return sendEmail(() => new AttendeeAddGuestsEmail(calendarEvent, attendee));

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

} else {
return sendEmail(() => new AttendeeAddGuestsEmail(calendarEvent, attendee));

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 — Email Logic Error (confidence: 100%)

The sendAddGuestsEmails function compares newGuests (the original input array from the mutation) against calendarEvent.attendees to decide which email template to send. However, the guests parameter passed from the handler (line 171) is the original input.guests array, not the filtered uniqueGuests. If a guest was already an attendee and was filtered out during DB insertion, they are still in newGuests, so the condition newGuests.includes(attendee.email) will match existing attendees and send them an AttendeeScheduledEmail (a full booking confirmation) instead of an AttendeeAddGuestsEmail (a notification about new guests). This is incorrect behavior.

Evidence:

  • Handler line 171: await sendAddGuestsEmails(evt, guests) — passes original unfiltered guests
  • Handler line 79-85: uniqueGuests is the filtered list, but it's not what's passed to the email function
  • email-manager.ts line 537: if (newGuests.includes(attendee.email)) will match existing attendees if they were in the original guest list

Agent: logic

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 (8 lines, 1 file))

The sendAddGuestsEmails function compares newGuests (the original input array from the mutation) against calendarEvent.attendees to decide which email template to send. However, the guests parameter passed from the handler (line 171) is the original input.guests array, not the filtered uniqueGuests. If a guest was already an attendee and was filtered out during DB insertion, they are still in newGuests, so the condition newGuests.includes(attendee.email) will match existing attendees and send them an AttendeeScheduledEmail (a full booking confirmation) instead of an AttendeeAddGuestsEmail (a notification about new guests). This is incorrect behavior.

Suggested change
return sendEmail(() => new AttendeeAddGuestsEmail(calendarEvent, attendee));
if (!newGuests.includes(attendee.email)) {
// Existing attendee: notify them that new guests were added to the event
} else {
// Newly added guest: send them a full booking confirmation
return sendEmail(() => new AttendeeScheduledEmail(calendarEvent, attendee));

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

}
})
);

await Promise.all(emailsToSend);

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: 85%)

The sendAddGuestsEmails function has inconsistent email dispatch logic. It sends OrganizerAddGuestsEmail to the organizer and team members, but then sends either AttendeeScheduledEmail or AttendeeAddGuestsEmail to attendees based on whether they are in the newGuests list. This logic appears inverted—new guests should receive AttendeeScheduledEmail, while existing attendees should receive AttendeeAddGuestsEmail.

Evidence:

  • Lines 536-543: Conditional checks if attendee email is in newGuests array, then sends AttendeeScheduledEmail, else sends AttendeeAddGuestsEmail
  • The naming suggests AttendeeAddGuestsEmail should notify existing attendees that NEW guests were added, not that they themselves are newly scheduled
  • AttendeeScheduledEmail is the standard confirmation email for newly scheduled attendees

Agent: style

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 — Code organization (confidence: 72%)

In sendAddGuestsEmails function, the logic for sending AttendeeAddGuestsEmail vs AttendeeScheduledEmail is based on whether the attendee is in the newGuests array. However, newly added guests should always receive AttendeeScheduledEmail (initial invitation), not AttendeeAddGuestsEmail (notification of additional guests). The current logic is inverted.

Evidence:

  • Lines 541-544 show: if newGuests.includes(attendee.email) → AttendeeScheduledEmail, else → AttendeeAddGuestsEmail
  • Semantically, new guests added to a booking should receive the full scheduled email (like initial attendees), not a 'guests added' notification
  • Existing attendees should receive AttendeeAddGuestsEmail to notify them of new guest additions
  • The logic appears backwards: newGuests should trigger AttendeeScheduledEmail, not the inverse

Agent: style

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))

In sendAddGuestsEmails function, the logic for sending AttendeeAddGuestsEmail vs AttendeeScheduledEmail is based on whether the attendee is in the newGuests array. However, newly added guests should always receive AttendeeScheduledEmail (initial invitation), not AttendeeAddGuestsEmail (notification of additional guests). The current logic is inverted.

Suggested change
await Promise.all(emailsToSend);
} else {
return sendEmail(() => new AttendeeScheduledEmail(calendarEvent, attendee));

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

};

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 Flow (confidence: 100%)

The sendAddGuestsEmails function accepts a newGuests parameter but the check newGuests.includes(attendee.email) compares against the original guests input from the caller, not the deduplicated list. In the handler (addGuests.handler.ts line 167), guests (the raw input) is passed, not uniqueGuests. This means if a guest email was already an attendee and was filtered out as a duplicate, they could still be matched in the email logic, though they'd be in the attendees list from the DB update so it would just send them an AttendeeScheduledEmail instead of AttendeeAddGuestsEmail.

Evidence:

  • email-manager.ts line 536: if (newGuests.includes(attendee.email))
  • addGuests.handler.ts line 167: await sendAddGuestsEmails(evt, guests) passes raw input, not uniqueGuests
  • Existing attendees who were submitted as 'new' guests would receive a full scheduled email instead of the add-guests notification email

Agent: logic

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 — Email Logic (confidence: 100%)

The sendAddGuestsEmails function does not guard against the organizer also being an attendee, which could result in the organizer receiving duplicate emails (one as organizer via OrganizerAddGuestsEmail and one as attendee via AttendeeAddGuestsEmail).

Evidence:

  • Edge case from spec: 'The organizer is also an attendee — ensure they don't receive duplicate emails'
  • Line 525: Sends OrganizerAddGuestsEmail to the organizer
  • Line 534-540: Iterates all attendees without filtering out the organizer email

Agent: logic

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 sendAddGuestsEmails function accepts a newGuests parameter but the check newGuests.includes(attendee.email) compares against the original guests input from the caller, not the deduplicated list. In the handler (addGuests.handler.ts line 167), guests (the raw input) is passed, not uniqueGuests. This means if a guest email was already an attendee and was filtered out as a duplicate, they could still be matched in the email logic, though they'd be in the attendees list from the DB update so it would just send them an AttendeeScheduledEmail instead of AttendeeAddGuestsEmail.

Suggested change
};
await sendAddGuestsEmails(evt, uniqueGuests);

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

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 (3 lines, 1 file))

The sendAddGuestsEmails function does not guard against the organizer also being an attendee, which could result in the organizer receiving duplicate emails (one as organizer via OrganizerAddGuestsEmail and one as attendee via AttendeeAddGuestsEmail).

Suggested change
};
.filter((attendee) => attendee.email !== calendarEvent.organizer.email)
.map((attendee) => {

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

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: 100%)

The sendAddGuestsEmails function sends AttendeeScheduledEmail to newly added guests instead of AttendeeAddGuestsEmail. This contradicts the expected behavior and email template purpose.

Evidence:

  • Line 540-542 in sendAddGuestsEmails: new guests receive AttendeeScheduledEmail instead of AttendeeAddGuestsEmail
  • The function imports both templates (line 19 and 50) but only uses AttendeeScheduledEmail for attendee emails
  • Expected behavior states: 'Upon successful guest addition, an email is sent to the newly added guests (AttendeeAddGuestsEmail)'
  • This mismatch means the specialized AttendeeAddGuestsEmail template is never used for its intended purpose

Agent: style

export const sendFeedbackEmail = async (feedback: Feedback) => {

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 — Data Flow - Email Deduplication Mismatch (confidence: 100%)

The sendAddGuestsEmails function receives newGuests (the original input array) and compares it against calendarEvent.attendees to determine email type. However, in the handler, guests (the raw input) is passed, not uniqueGuests (the deduplicated list). This means if a user submits an email that already exists as an attendee, that email won't be in the new attendee list but newGuests.includes(attendee.email) will be true, causing an existing attendee to receive a AttendeeScheduledEmail (new booking email) instead of the AttendeeAddGuestsEmail notification.

Evidence:

  • Handler line 164: await sendAddGuestsEmails(evt, guests) — passes raw guests, not uniqueGuests
  • email-manager.ts line 539: if (newGuests.includes(attendee.email)) — checks against raw input
  • If guest 'existing@test.com' is already an attendee and is in the guests input (before dedup), they'd get a scheduled email instead of an add-guests notification

Agent: logic

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 — Cross-module Consistency (confidence: 87%)

The sendAddGuestsEmails function accepts a newGuests: string[] parameter but uses it only to decide which email template to send to each attendee. However, the caller in addGuests.handler.ts passes the original guests input (which may include duplicates that were filtered out) rather than the uniqueGuests array. This means an attendee who was already on the booking but was re-submitted in the input could receive an AttendeeScheduledEmail instead of an AttendeeAddGuestsEmail.

Evidence:

  • email-manager.ts line 536-541: if (newGuests.includes(attendee.email)) determines which template to use
  • addGuests.handler.ts line 164: await sendAddGuestsEmails(evt, guests) passes raw guests input
  • The uniqueGuests variable (filtered list) is not passed to the email function

Agent: architecture

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 — Email Dispatch Logic (confidence: 100%)

The sendAddGuestsEmails function accepts a newGuests parameter but then checks guests (from the outer scope of the caller) against calendarEvent.attendees. The logic sends AttendeeScheduledEmail to new guests and AttendeeAddGuestsEmail to existing attendees, but the check newGuests.includes(attendee.email) compares against the original input emails. Since the handler passes the full calendarEvent with all attendees (including pre-existing ones), every attendee gets an email — but new guests get a booking confirmation while existing attendees get a 'guests added' notification. This works but the parameter naming (newGuests vs the raw guests input which may include duplicates that were filtered) could cause incorrect email routing if the caller passes unfiltered input.

Evidence:

  • In addGuests.handler.ts line 163: await sendAddGuestsEmails(evt, guests) — passes raw guests input, not uniqueGuests
  • In email-manager.ts line 537: if (newGuests.includes(attendee.email)) — compares against raw input
  • Line 522: function signature accepts newGuests: string[]
  • Line 536-540: if (newGuests.includes(attendee.email)) determines email type
  • In addGuests.handler.ts line 166: await sendAddGuestsEmails(evt, guests) passes the original guests input, not uniqueGuests
  • If a guest email was already an attendee (and thus filtered from DB insert), they would still match newGuests.includes() and receive an AttendeeScheduledEmail instead of AttendeeAddGuestsEmail

Agent: architecture

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 sendAddGuestsEmails function accepts a newGuests parameter but then checks guests (from the outer scope of the caller) against calendarEvent.attendees. The logic sends AttendeeScheduledEmail to new guests and AttendeeAddGuestsEmail to existing attendees, but the check newGuests.includes(attendee.email) compares against the original input emails. Since the handler passes the full calendarEvent with all attendees (including pre-existing ones), every attendee gets an email — but new guests get a booking confirmation while existing attendees get a 'guests added' notification. This works but the parameter naming (newGuests vs the raw guests input which may include duplicates that were filtered) could cause incorrect email routing if the caller passes unfiltered input.

Suggested change
export const sendFeedbackEmail = async (feedback: Feedback) => {
await sendAddGuestsEmails(evt, uniqueGuests);

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

await sendEmail(() => new FeedbackEmail(feedback));

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 — Email Dispatch Logic (confidence: 100%)

The sendAddGuestsEmails function accepts newGuests: string[] parameter but the handler at addGuests.handler.ts:166 passes guests (the original input) rather than uniqueGuests (the deduplicated list). This means if a guest email was already an attendee and was filtered out by uniqueGuests, the email logic in sendAddGuestsEmails will still check against the original unfiltered list, potentially sending the wrong email type to existing attendees.

Evidence:

  • addGuests.handler.ts line 166: await sendAddGuestsEmails(evt, guests) passes original guests input
  • addGuests.handler.ts line 78: uniqueGuests is the filtered list actually added to the booking
  • email-manager.ts line 539-544: Uses newGuests.includes(attendee.email) to decide between AttendeeScheduledEmail and AttendeeAddGuestsEmail
  • If an existing attendee's email is in the original guests array but was filtered out as a duplicate, they would receive AttendeeScheduledEmail instead of AttendeeAddGuestsEmail

Agent: architecture

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 sendAddGuestsEmails function accepts newGuests: string[] parameter but the handler at addGuests.handler.ts:166 passes guests (the original input) rather than uniqueGuests (the deduplicated list). This means if a guest email was already an attendee and was filtered out by uniqueGuests, the email logic in sendAddGuestsEmails will still check against the original unfiltered list, potentially sending the wrong email type to existing attendees.

Suggested change
await sendEmail(() => new FeedbackEmail(feedback));
await sendAddGuestsEmails(evt, uniqueGuests);

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

};
Expand Down
10 changes: 10 additions & 0 deletions packages/emails/src/templates/AttendeeAddGuestsEmail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { AttendeeScheduledEmail } from "./AttendeeScheduledEmail";

export const AttendeeAddGuestsEmail = (props: React.ComponentProps<typeof AttendeeScheduledEmail>) => (
<AttendeeScheduledEmail
title="new_guests_added"
headerType="calendarCircle"
subject="guests_added_event_type_subject"
{...props}
/>
);

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 — Unused Import/Parameter (confidence: 84%)

The React component AttendeeAddGuestsEmail in the src/templates/ directory is correctly exported from index.ts, but the actual email sending in email-manager.ts uses AttendeeScheduledEmail directly for new guests (line 537) rather than AttendeeAddGuestsEmail. The AttendeeAddGuestsEmail class from templates/attendee-add-guests-email.ts (without src/) is what's used for existing attendees. This means the React template AttendeeAddGuestsEmail is used via renderEmail('AttendeeAddGuestsEmail', ...) in the class-based template, which is the correct pattern. No issue, just noting the dual template system (class-based + React) is correctly wired.

Evidence:

  • email-manager.ts imports from ./templates/attendee-add-guests-email (class)
  • The class in templates/attendee-add-guests-email.ts calls renderEmail('AttendeeAddGuestsEmail', ...) which uses the React component
  • Both layers are correctly connected

Agent: architecture

11 changes: 11 additions & 0 deletions packages/emails/src/templates/OrganizerAddGuestsEmail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { OrganizerScheduledEmail } from "./OrganizerScheduledEmail";

export const OrganizerAddGuestsEmail = (props: React.ComponentProps<typeof OrganizerScheduledEmail>) => (
<OrganizerScheduledEmail
title="new_guests_added"
headerType="calendarCircle"
subject="guests_added_event_type_subject"
callToAction={null}
{...props}
/>
);
2 changes: 2 additions & 0 deletions packages/emails/src/templates/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,6 @@ export { AdminOrganizationNotificationEmail } from "./AdminOrganizationNotificat
export { BookingRedirectEmailNotification } from "./BookingRedirectEmailNotification";
export { VerifyEmailChangeEmail } from "./VerifyEmailChangeEmail";
export { OrganizationCreationEmail } from "./OrganizationCreationEmail";
export { OrganizerAddGuestsEmail } from "./OrganizerAddGuestsEmail";
export { AttendeeAddGuestsEmail } from "./AttendeeAddGuestsEmail";
export { OrganizationAdminNoSlotsEmail } from "./OrganizationAdminNoSlots";
34 changes: 34 additions & 0 deletions packages/emails/templates/attendee-add-guests-email.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { renderEmail } from "../";
import generateIcsString from "../lib/generateIcsString";
import AttendeeScheduledEmail from "./attendee-scheduled-email";

export default class AttendeeAddGuestsEmail extends AttendeeScheduledEmail {
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
return {
icalEvent: {
filename: "event.ics",
content: generateIcsString({
event: this.calEvent,
title: this.t("new_guests_added"),
subtitle: this.t("emailed_you_and_any_other_attendees"),
role: "attendee",
status: "CONFIRMED",
}),
method: "REQUEST",
},
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: this.calEvent.organizer.email,
subject: `${this.t("guests_added_event_type_subject", {
eventType: this.calEvent.type,
name: this.calEvent.team?.name || this.calEvent.organizer.name,
date: this.getFormattedDate(),
})}`,
html: await renderEmail("AttendeeAddGuestsEmail", {
calEvent: this.calEvent,
attendee: this.attendee,
}),
text: this.getTextBody("new_guests_added"),
};
}
}

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: 76%)

New email template class duplicates node-mailer payload logic from parent AttendeeScheduledEmail. Only subtitle changes (new_guests_added vs emailed_you_and_any_other_attendees). Consider if this warrants a full class or if a simpler composition pattern would reduce duplication.

Evidence:

  • Lines 5-16 mirror AttendeeScheduledEmail structure with identical icalEvent, to, from, replyTo setup
  • Only change is line 12: title and subtitle use 'new_guests_added' instead of standard scheduled message
  • Similar duplication in OrganizerAddGuestsEmail (organizer-add-guests-email.ts)
  • React component versions (src/templates/) use composition effectively but legacy TS versions duplicate code

Agent: style

38 changes: 38 additions & 0 deletions packages/emails/templates/organizer-add-guests-email.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { APP_NAME } from "@calcom/lib/constants";

import { renderEmail } from "../";
import generateIcsString from "../lib/generateIcsString";
import OrganizerScheduledEmail from "./organizer-scheduled-email";

export default class OrganizerAddGuestsEmail extends OrganizerScheduledEmail {
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
const toAddresses = [this.teamMember?.email || this.calEvent.organizer.email];

return {
icalEvent: {
filename: "event.ics",
content: generateIcsString({
event: this.calEvent,
title: this.t("new_guests_added"),
subtitle: this.t("emailed_you_and_any_other_attendees"),
role: "organizer",
status: "CONFIRMED",
}),
method: "REQUEST",
},
from: `${APP_NAME} <${this.getMailerOptions().from}>`,
to: toAddresses.join(","),
replyTo: [this.calEvent.organizer.email, ...this.calEvent.attendees.map(({ email }) => email)],
subject: `${this.t("guests_added_event_type_subject", {
eventType: this.calEvent.type,

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 — Information Disclosure — Attendee PII in ReplyTo Header (confidence: 93%)

The organizer notification email sets replyTo to include ALL attendee email addresses. Email clients (Gmail, Outlook) expose these addresses in the UI when the recipient clicks Reply or Reply All. For bookings with many attendees/guests, this leaks every attendee's email address to the organizer and potentially to other recipients.

Evidence:

  • Line 26-27: replyTo: [this.calEvent.organizer.email, ...this.calEvent.attendees.map(({ email }) => email)]
  • After addGuests runs, calEvent.attendees includes ALL existing attendees plus newly added guests
  • This exposes the full attendee list as visible email headers to anyone who receives the organizer email

Agent: security

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))

The organizer notification email sets replyTo to include ALL attendee email addresses. Email clients (Gmail, Outlook) expose these addresses in the UI when the recipient clicks Reply or Reply All. For bookings with many attendees/guests, this leaks every attendee's email address to the organizer and potentially to other recipients.

Suggested change
eventType: this.calEvent.type,
replyTo: this.calEvent.organizer.email,

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

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 — Defensive Coding (confidence: 81%)

The subject line references this.calEvent.attendees[0].name without checking that the attendees array is non-empty. While in practice there should always be at least one attendee, an edge case with an empty attendees list would cause a runtime error.

Evidence:

  • Line 27: name: this.calEvent.attendees[0].name — no bounds check

Agent: logic

name: this.calEvent.attendees[0].name,

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 Null Safety (confidence: 100%)

The organizer email subject accesses this.calEvent.attendees[0].name without checking if the attendees array is non-empty. If somehow a CalendarEvent is constructed with an empty attendees array, this will throw a runtime error crashing the email send.

Evidence:

  • Line 28: name: this.calEvent.attendees[0].name
  • No guard for empty attendees array

Agent: logic

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 organizer email subject accesses this.calEvent.attendees[0].name without checking if the attendees array is non-empty. If somehow a CalendarEvent is constructed with an empty attendees array, this will throw a runtime error crashing the email send.

Suggested change
name: this.calEvent.attendees[0].name,
name: this.calEvent.attendees[0]?.name ?? "",

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

date: this.getFormattedDate(),

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 patterns (confidence: 93%)

Email subject name field uses first attendee instead of organizer name. Subject should consistently reference who the event is with.

Evidence:

  • Line 28: name: this.calEvent.attendees[0].name uses first attendee's name
  • Expected pattern from other emails: subject typically shows organizer or team name
  • If attendee list changes or is empty, this could reference wrong person in subject
  • Inconsistent with OrganizerScheduledEmail naming patterns in codebase

Agent: style

})}`,
html: await renderEmail("OrganizerAddGuestsEmail", {
attendee: this.calEvent.organizer,
calEvent: this.calEvent,
}),
text: this.getTextBody("new_guests_added"),
};
}
}
19 changes: 19 additions & 0 deletions packages/trpc/server/routers/viewer/bookings/_router.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import authedProcedure from "../../../procedures/authedProcedure";
import publicProcedure from "../../../procedures/publicProcedure";
import { router } from "../../../trpc";
import { ZAddGuestsInputSchema } from "./addGuests.schema";
import { ZConfirmInputSchema } from "./confirm.schema";
import { ZEditLocationInputSchema } from "./editLocation.schema";
import { ZFindInputSchema } from "./find.schema";
Expand All @@ -14,6 +15,7 @@ type BookingsRouterHandlerCache = {
get?: typeof import("./get.handler").getHandler;
requestReschedule?: typeof import("./requestReschedule.handler").requestRescheduleHandler;
editLocation?: typeof import("./editLocation.handler").editLocationHandler;
addGuests?: typeof import("./addGuests.handler").addGuestsHandler;
confirm?: typeof import("./confirm.handler").confirmHandler;
getBookingAttendees?: typeof import("./getBookingAttendees.handler").getBookingAttendeesHandler;
find?: typeof import("./find.handler").getHandler;
Expand Down Expand Up @@ -74,6 +76,23 @@ export const bookingsRouter = router({
input,
});
}),
addGuests: authedProcedure.input(ZAddGuestsInputSchema).mutation(async ({ input, ctx }) => {
if (!UNSTABLE_HANDLER_CACHE.addGuests) {
UNSTABLE_HANDLER_CACHE.addGuests = await import("./addGuests.handler").then(
(mod) => mod.addGuestsHandler
);
}

// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.addGuests) {
throw new Error("Failed to load handler");
}

return UNSTABLE_HANDLER_CACHE.addGuests({
ctx,
input,
});
}),

confirm: authedProcedure.input(ZConfirmInputSchema).mutation(async ({ input, ctx }) => {
if (!UNSTABLE_HANDLER_CACHE.confirm) {
Expand Down
Loading