-
Notifications
You must be signed in to change notification settings - Fork 0
Automated Test: guest-management-enhanced #376
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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); | ||
| } | ||
| }; | ||
|
|
||
| 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> | ||
| ); | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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"; | ||
|
|
@@ -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"; | ||
|
|
@@ -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); | ||
|
Comment on lines
+525
to
+549
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Prevent leakage of hidden notes in attendee emails. When 🛡️ Suggested attendee-safe payload export const sendAddGuestsEmails = async (calEvent: CalendarEvent, newGuests: string[]) => {
const calendarEvent = formatCalEvent(calEvent);
+ const attendeeEvent = {
+ ...calendarEvent,
+ ...(calendarEvent.hideCalendarNotes && { additionalNotes: undefined }),
+ };
const emailsToSend: Promise<unknown>[] = [];
...
emailsToSend.push(
...calendarEvent.attendees.map((attendee) => {
if (newGuests.includes(attendee.email)) {
- return sendEmail(() => new AttendeeScheduledEmail(calendarEvent, attendee));
+ return sendEmail(() => new AttendeeScheduledEmail(attendeeEvent, attendee));
} else {
- return sendEmail(() => new AttendeeAddGuestsEmail(calendarEvent, attendee));
+ return sendEmail(() => new AttendeeAddGuestsEmail(attendeeEvent, attendee));
}
})
);🤖 Prompt for AI AgentsHonor event-type email disable flags for add-guests sends. Currently all organizer/attendee emails are sent regardless of event-type settings, which diverges from other flows. 🛠️ Proposed guard and signature update-export const sendAddGuestsEmails = async (calEvent: CalendarEvent, newGuests: string[]) => {
+export const sendAddGuestsEmails = async (
+ calEvent: CalendarEvent,
+ newGuests: string[],
+ eventTypeMetadata?: EventTypeMetadata
+) => {
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 }))
- );
- }
- }
+ if (!eventTypeDisableHostEmail(eventTypeMetadata)) {
+ 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));
- }
- })
- );
+ if (!eventTypeDisableAttendeeEmail(eventTypeMetadata)) {
+ emailsToSend.push(
+ ...calendarEvent.attendees.map((attendee) => {
+ if (newGuests.includes(attendee.email)) {
+ return sendEmail(() => new AttendeeScheduledEmail(calendarEvent, attendee));
+ }
+ return sendEmail(() => new AttendeeAddGuestsEmail(calendarEvent, attendee));
+ })
+ );
+ }
await Promise.all(emailsToSend);
};🤖 Prompt for AI Agents |
||
| }; | ||
| export const sendFeedbackEmail = async (feedback: Feedback) => { | ||
| await sendEmail(() => new FeedbackEmail(feedback)); | ||
| }; | ||
|
|
||
| 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} | ||
| /> | ||
| ); |
| 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} | ||
| /> | ||
| ); |
| 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"), | ||
| }; | ||
| } | ||
| } |
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
While the "add guests" flow should always have attendees, a defensive check or fallback avoids a runtime crash if this email is ever triggered with unexpected data. 🛡️ Suggested defensive fallback subject: `${this.t("guests_added_event_type_subject", {
eventType: this.calEvent.type,
- name: this.calEvent.attendees[0].name,
+ name: this.calEvent.attendees[0]?.name ?? this.calEvent.organizer.name,
date: this.getFormattedDate(),
})}`,📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||
| html: await renderEmail("OrganizerAddGuestsEmail", { | ||||||||||||||||||||||
| attendee: this.calEvent.organizer, | ||||||||||||||||||||||
| calEvent: this.calEvent, | ||||||||||||||||||||||
| }), | ||||||||||||||||||||||
| text: this.getTextBody("new_guests_added"), | ||||||||||||||||||||||
| }; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Clear the invalid-state banner once input changes.
Currently the warning stays visible after correction until submission/cancel.
🧩 Suggested reset on change
🤖 Prompt for AI Agents