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;
});
Comment on lines +26 to +29
Copy link

Choose a reason for hiding this comment

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

style: The Zod schema is duplicated from the backend schema file. Consider importing the schema from packages/trpc/server/routers/viewer/bookings/addGuests.schema.ts to maintain single source of truth

const { isOpenDialog, setIsOpenDialog, bookingId } = props;
const utils = trpc.useUtils();
const [multiEmailValue, setMultiEmailValue] = useState<string[]>([""]);
Copy link

Choose a reason for hiding this comment

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

style: Consider using an empty array [] instead of [""] as the initial state to be more semantically correct and avoid the validation issue mentioned above

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

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}
/>
);
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,
Copy link

Choose a reason for hiding this comment

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

logic: Unsafe array access - calEvent.attendees[0] will throw if attendees array is empty. Add validation or use optional chaining.

date: this.getFormattedDate(),
})}`,
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 }) => {
Copy link

Choose a reason for hiding this comment

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

logic: Using authedProcedure instead of bookingsProcedure means this endpoint bypasses the existing booking authorization middleware in util.ts. This creates inconsistent security patterns compared to other booking operations like editLocation.

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