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,
},
];

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;
});
const { isOpenDialog, setIsOpenDialog, bookingId } = props;
const utils = trpc.useUtils();
const [multiEmailValue, setMultiEmailValue] = useState<string[]>([""]);
const [isInvalidEmail, setIsInvalidEmail] = useState(false);

const addGuestsMutation = trpc.viewer.bookings.addGuests.useMutation({
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");
},
});

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Improve empty state validation logic.

The current check multiEmailValue.length === 0 may not work correctly with the initial state of [""].

const handleAdd = () => {
-  if (multiEmailValue.length === 0) {
+  const validEmails = multiEmailValue.filter(email => email.trim() !== '');
+  if (validEmails.length === 0) {
    return;
  }
-  const validationResult = ZAddGuestsInputSchema.safeParse(multiEmailValue);
+  const validationResult = ZAddGuestsInputSchema.safeParse(validEmails);
  if (validationResult.success) {
-    addGuestsMutation.mutate({ bookingId, guests: multiEmailValue });
+    addGuestsMutation.mutate({ bookingId, guests: validEmails });
  } else {
    setIsInvalidEmail(true);
  }
};

This ensures only non-empty email strings are processed and sent to the server.

📝 Committable suggestion

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

Suggested change
const handleAdd = () => {
if (multiEmailValue.length === 0) {
return;
}
const validationResult = ZAddGuestsInputSchema.safeParse(multiEmailValue);
if (validationResult.success) {
addGuestsMutation.mutate({ bookingId, guests: multiEmailValue });
} else {
setIsInvalidEmail(true);
}
};
const handleAdd = () => {
const validEmails = multiEmailValue.filter(email => email.trim() !== '');
if (validEmails.length === 0) {
return;
}
const validationResult = ZAddGuestsInputSchema.safeParse(validEmails);
if (validationResult.success) {
addGuestsMutation.mutate({ bookingId, guests: validEmails });
} else {
setIsInvalidEmail(true);
}
};
🤖 Prompt for AI Agents
In apps/web/components/dialog/AddGuestsDialog.tsx around lines 48 to 58, the
current empty state check using multiEmailValue.length === 0 does not handle
cases where the array contains empty strings like [""] properly. Update the
validation to filter out empty strings from multiEmailValue before checking if
there are any valid emails to process. Only proceed with mutation if there is at
least one non-empty email string, ensuring that empty or blank emails are not
sent to the server.


return (
<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 (

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));
} else {
return sendEmail(() => new AttendeeAddGuestsEmail(calendarEvent, attendee));
}
})
);

await Promise.all(emailsToSend);
};
export const sendFeedbackEmail = async (feedback: Feedback) => {
await sendEmail(() => new FeedbackEmail(feedback));
};
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}
/>
);
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}
/>
Comment on lines +3 to +10

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Order of spread makes props override your “constants”

{...props} is applied after the hard-coded props, so a caller can change title, subject, etc., defeating the locked-in template.
If these values must remain fixed, move the spread before the explicit props:

-  <OrganizerScheduledEmail
-    title="new_guests_added"
-    headerType="calendarCircle"
-    subject="guests_added_event_type_subject"
-    callToAction={null}
-    {...props}
-  />
+  <OrganizerScheduledEmail
+    {...props}
+    title="new_guests_added"
+    headerType="calendarCircle"
+    subject="guests_added_event_type_subject"
+    callToAction={null}
+  />
📝 Committable suggestion

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

Suggested change
export const OrganizerAddGuestsEmail = (props: React.ComponentProps<typeof OrganizerScheduledEmail>) => (
<OrganizerScheduledEmail
title="new_guests_added"
headerType="calendarCircle"
subject="guests_added_event_type_subject"
callToAction={null}
{...props}
/>
export const OrganizerAddGuestsEmail = (
props: React.ComponentProps<typeof OrganizerScheduledEmail>,
) => (
<OrganizerScheduledEmail
{...props}
title="new_guests_added"
headerType="calendarCircle"
subject="guests_added_event_type_subject"
callToAction={null}
/>
);
🤖 Prompt for AI Agents
In packages/emails/src/templates/OrganizerAddGuestsEmail.tsx around lines 3 to
10, the spread operator {...props} is placed after the fixed props, allowing
callers to override constants like title and subject. To fix this, move
{...props} before the explicit props so that the hard-coded values cannot be
overridden by incoming props, ensuring the template's fixed values remain locked
in.

);
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"),
};
}
}
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,
name: this.calEvent.attendees[0].name,
date: this.getFormattedDate(),
})}`,
Comment on lines +26 to +30

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Add safety check for empty attendees array.

Accessing this.calEvent.attendees[0].name without checking if the array is empty could cause a runtime error.

       subject: `${this.t("guests_added_event_type_subject", {
         eventType: this.calEvent.type,
-        name: this.calEvent.attendees[0].name,
+        name: this.calEvent.attendees[0]?.name || "Guest",
         date: this.getFormattedDate(),
       })}`,
📝 Committable suggestion

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

Suggested change
subject: `${this.t("guests_added_event_type_subject", {
eventType: this.calEvent.type,
name: this.calEvent.attendees[0].name,
date: this.getFormattedDate(),
})}`,
subject: `${this.t("guests_added_event_type_subject", {
eventType: this.calEvent.type,
name: this.calEvent.attendees[0]?.name || "Guest",
date: this.getFormattedDate(),
})}`,
🤖 Prompt for AI Agents
In packages/emails/templates/organizer-add-guests-email.ts around lines 26 to
30, the code accesses this.calEvent.attendees[0].name without verifying if the
attendees array is non-empty, which can cause a runtime error. Add a safety
check to ensure this.calEvent.attendees has at least one element before
accessing the first attendee's name, and provide a fallback value if the array
is empty to prevent errors.

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
Loading