diff --git a/packages/emails/email-manager.ts b/packages/emails/email-manager.ts index 5601b1b506..ed5af1d773 100644 --- a/packages/emails/email-manager.ts +++ b/packages/emails/email-manager.ts @@ -244,16 +244,18 @@ export const sendRoundRobinRescheduledEmailsAndSMS = async ( export const sendReassignedUpdatedEmailsAndSMS = async ({ calEvent, eventTypeMetadata, + showAttendees, }: { calEvent: CalendarEvent; eventTypeMetadata?: EventTypeMetadata; + showAttendees: boolean; }) => { const organizationSettings = await fetchOrganizationEmailSettings(calEvent.organizationId); if (shouldSkipAttendeeEmailWithSettings(eventTypeMetadata, organizationSettings, EmailType.REASSIGNED)) return; const emailsToSend = calEvent.attendees.map((attendee) => - sendEmail(() => new AttendeeUpdatedEmail(calEvent, attendee)) + sendEmail(() => new AttendeeUpdatedEmail(calEvent, attendee, showAttendees)) ); await Promise.all(emailsToSend); diff --git a/packages/features/ee/managed-event-types/reassignment/services/ManagedEventManualReassignmentService.ts b/packages/features/ee/managed-event-types/reassignment/services/ManagedEventManualReassignmentService.ts index ba8e12cc52..a3d198e708 100644 --- a/packages/features/ee/managed-event-types/reassignment/services/ManagedEventManualReassignmentService.ts +++ b/packages/features/ee/managed-event-types/reassignment/services/ManagedEventManualReassignmentService.ts @@ -702,6 +702,7 @@ export class ManagedEventManualReassignmentService { slug: targetEventTypeDetails.slug, description: newBooking.description, schedulingType: parentEventType.schedulingType, + seatsPerTimeSlot: targetEventTypeDetails.seatsPerTimeSlot, }) .withOrganizer({ id: newUser.id, @@ -784,6 +785,7 @@ export class ManagedEventManualReassignmentService { await sendReassignedUpdatedEmailsAndSMS({ calEvent, eventTypeMetadata, + showAttendees: !!targetEventTypeDetails.seatsShowAttendees, }); logger.info("Sent update emails to attendees"); } diff --git a/packages/features/ee/round-robin/roundRobinManualReassignment.test.ts b/packages/features/ee/round-robin/roundRobinManualReassignment.test.ts index 192c6b2c32..3c80e86fba 100644 --- a/packages/features/ee/round-robin/roundRobinManualReassignment.test.ts +++ b/packages/features/ee/round-robin/roundRobinManualReassignment.test.ts @@ -16,6 +16,8 @@ import { import { setupAndTeardown } from "@calcom/testing/lib/bookingScenario/setupAndTeardown"; import { describe, vi, expect } from "vitest"; +import { v4 as uuidv4 } from "uuid"; +import { parse } from "node-html-parser"; import { OrganizerDefaultConferencingAppType } from "@calcom/app-store/locations"; import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; @@ -878,3 +880,155 @@ describe("roundRobinManualReassignment - Location Changes", () => { ).rejects.toThrow("Failed to set video conferencing link, but the meeting has been rescheduled"); }); }); + +describe("roundRobinManualReassignment - Seated Events", () => { + setupAndTeardown(); + + test("should not expose other attendees in updated email when seatsShowAttendees is disabled for seated round-robin event", async ({ + emails, + }) => { + const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default; + const eventManagerRescheduleSpy = await mockEventManagerReschedule(); + + const testDestinationCalendar = createTestDestinationCalendar(); + const originalHost = createTestUser({ id: 1, destinationCalendar: testDestinationCalendar }); + const newHost = createTestUser({ id: 2 }); + const users = [originalHost, newHost, createTestUser({ id: 3 })]; + + const { dateString: dateStringPlusOne } = getDate({ dateIncrement: 1 }); + + const bookingToReassignUid = "seated-booking-manual-reassign"; + const attendee1Email = "attendee1@test.com"; + const attendee2Email = "attendee2@test.com"; + const attendee3Email = "attendee3@test.com"; + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slug: "round-robin-seated-event", + schedulingType: SchedulingType.ROUND_ROBIN, + length: 45, + seatsPerTimeSlot: 5, + seatsShowAttendees: false, + users: users.map((user) => ({ id: user.id })), + hosts: users.map((user) => ({ userId: user.id, isFixed: false })), + }, + ], + bookings: [ + await createTestBooking({ + eventTypeId: 1, + userId: originalHost.id, + bookingId: 125, + bookingUid: bookingToReassignUid, + attendees: [ + { + ...getMockBookingAttendee({ + id: 2, + name: "Attendee 1", + email: attendee1Email, + locale: "en", + timeZone: "Asia/Kolkata", + }), + bookingSeat: { + referenceUid: uuidv4(), + data: {}, + }, + }, + { + ...getMockBookingAttendee({ + id: 3, + name: "Attendee 2", + email: attendee2Email, + locale: "en", + timeZone: "Asia/Kolkata", + }), + bookingSeat: { + referenceUid: uuidv4(), + data: {}, + }, + }, + { + ...getMockBookingAttendee({ + id: 4, + name: "Attendee 3", + email: attendee3Email, + locale: "en", + timeZone: "Asia/Kolkata", + }), + bookingSeat: { + referenceUid: uuidv4(), + data: {}, + }, + }, + ], + }), + ], + organizer: originalHost, + usersApartFromOrganizer: users.slice(1), + }) + ); + + await roundRobinManualReassignment({ + bookingId: 125, + newUserId: newHost.id, + orgId: null, + reassignedById: 1, + }); + + // Verify that updated emails were sent to all attendees + const updatedEmails = emails.get().filter((email) => { + const subject = email.subject || ""; + return subject.includes("updated") || subject.includes("event_type_has_been_updated"); + }); + + // Check that each attendee received an email + expect(updatedEmails.some((email) => email.to.includes(attendee1Email))).toBe(true); + expect(updatedEmails.some((email) => email.to.includes(attendee2Email))).toBe(true); + expect(updatedEmails.some((email) => email.to.includes(attendee3Email))).toBe(true); + + // Verify that each attendee's email does not contain other attendees' information + const attendee1EmailContent = updatedEmails.find((email) => email.to.includes(attendee1Email)); + if (attendee1EmailContent) { + const emailHtml = parse(attendee1EmailContent.html); + const emailText = emailHtml.innerText; + + // Should not contain other attendees' emails or names + expect(emailText).not.toContain(attendee2Email); + expect(emailText).not.toContain(attendee3Email); + expect(emailText).not.toContain("Attendee 2"); + expect(emailText).not.toContain("Attendee 3"); + // Should contain their own info + expect(emailText).toContain("Attendee 1"); + } + + const attendee2EmailContent = updatedEmails.find((email) => email.to.includes(attendee2Email)); + if (attendee2EmailContent) { + const emailHtml = parse(attendee2EmailContent.html); + const emailText = emailHtml.innerText; + + // Should not contain other attendees' emails or names + expect(emailText).not.toContain(attendee1Email); + expect(emailText).not.toContain(attendee3Email); + expect(emailText).not.toContain("Attendee 1"); + expect(emailText).not.toContain("Attendee 3"); + // Should contain their own info + expect(emailText).toContain("Attendee 2"); + } + + const attendee3EmailContent = updatedEmails.find((email) => email.to.includes(attendee3Email)); + if (attendee3EmailContent) { + const emailHtml = parse(attendee3EmailContent.html); + const emailText = emailHtml.innerText; + + // Should not contain other attendees' emails or names + expect(emailText).not.toContain(attendee1Email); + expect(emailText).not.toContain(attendee2Email); + expect(emailText).not.toContain("Attendee 1"); + expect(emailText).not.toContain("Attendee 2"); + // Should contain their own info + expect(emailText).toContain("Attendee 3"); + } + }); +}); diff --git a/packages/features/ee/round-robin/roundRobinManualReassignment.ts b/packages/features/ee/round-robin/roundRobinManualReassignment.ts index 9d13c57c04..5b822f57a3 100644 --- a/packages/features/ee/round-robin/roundRobinManualReassignment.ts +++ b/packages/features/ee/round-robin/roundRobinManualReassignment.ts @@ -314,6 +314,7 @@ export const roundRobinManualReassignment = async ({ ...(platformClientParams ? platformClientParams : {}), conferenceCredentialId: conferenceCredentialId ?? undefined, organizationId: orgId, + seatsPerTimeSlot: eventType.seatsPerTimeSlot, }; if (hasOrganizerChanged) { @@ -445,6 +446,7 @@ export const roundRobinManualReassignment = async ({ await sendReassignedUpdatedEmailsAndSMS({ calEvent: evtWithoutCancellationReason, eventTypeMetadata: eventType?.metadata as EventTypeMetadata, + showAttendees: !!eventType.seatsShowAttendees, }); } diff --git a/packages/features/ee/round-robin/roundRobinReassignment.test.ts b/packages/features/ee/round-robin/roundRobinReassignment.test.ts index d717da191c..cc355125d8 100644 --- a/packages/features/ee/round-robin/roundRobinReassignment.test.ts +++ b/packages/features/ee/round-robin/roundRobinReassignment.test.ts @@ -16,6 +16,8 @@ import { import { setupAndTeardown } from "@calcom/testing/lib/bookingScenario/setupAndTeardown"; import { describe, vi, expect } from "vitest"; +import { v4 as uuidv4 } from "uuid"; +import { parse } from "node-html-parser"; import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; import { SchedulingType, BookingStatus, WorkflowMethods } from "@calcom/prisma/enums"; @@ -164,6 +166,8 @@ describe("roundRobinReassignment test", () => { await roundRobinReassignment({ bookingId: 123, + orgId: null, + reassignedById: originalHost.id, }); expect(eventManagerSpy).toBeCalledTimes(1); @@ -276,6 +280,8 @@ describe("roundRobinReassignment test", () => { await roundRobinReassignment({ bookingId: 123, + orgId: null, + reassignedById: fixedHost.id, }); expect(eventManagerSpy).toBeCalledTimes(1); @@ -303,4 +309,178 @@ describe("roundRobinReassignment test", () => { expect(attendees.some((attendee) => attendee.email === newHost.email)).toBe(true); }); + + test("should not expose other attendees in updated email when seatsShowAttendees is disabled for seated round-robin event", async ({ + emails, + }) => { + const roundRobinReassignment = (await import("./roundRobinReassignment")).default; + const EventManager = (await import("@calcom/features/bookings/lib/EventManager")).default; + + const eventManagerSpy = vi.spyOn(EventManager.prototype as any, "reschedule"); + eventManagerSpy.mockClear(); + eventManagerSpy.mockResolvedValue({ referencesToCreate: [] }); + + const users = testUsers; + const originalHost = users[0]; + const newHost = users[1]; + + const { dateString: dateStringPlusOne } = getDate({ dateIncrement: 1 }); + const { dateString: dateStringMinusOne } = getDate({ dateIncrement: -1 }); + + const bookingToReassignUid = "seated-booking-to-reassign"; + const attendee1Email = "attendee1@test.com"; + const attendee2Email = "attendee2@test.com"; + const attendee3Email = "attendee3@test.com"; + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slug: "round-robin-seated-event", + schedulingType: SchedulingType.ROUND_ROBIN, + length: 45, + seatsPerTimeSlot: 5, + seatsShowAttendees: false, + users: users.map((user) => ({ id: user.id })), + hosts: users.map((user) => ({ userId: user.id, isFixed: false })), + }, + ], + bookings: [ + { + id: 123, + eventTypeId: 1, + userId: originalHost.id, + uid: bookingToReassignUid, + status: BookingStatus.ACCEPTED, + startTime: `${dateStringPlusOne}T05:00:00.000Z`, + endTime: `${dateStringPlusOne}T05:15:00.000Z`, + attendees: [ + { + ...getMockBookingAttendee({ + id: 2, + name: "Attendee 1", + email: attendee1Email, + locale: "en", + timeZone: "Asia/Kolkata", + }), + bookingSeat: { + referenceUid: uuidv4(), + data: {}, + }, + }, + { + ...getMockBookingAttendee({ + id: 3, + name: "Attendee 2", + email: attendee2Email, + locale: "en", + timeZone: "Asia/Kolkata", + }), + bookingSeat: { + referenceUid: uuidv4(), + data: {}, + }, + }, + { + ...getMockBookingAttendee({ + id: 4, + name: "Attendee 3", + email: attendee3Email, + locale: "en", + timeZone: "Asia/Kolkata", + }), + bookingSeat: { + referenceUid: uuidv4(), + data: {}, + }, + }, + ], + }, + { + id: 456, + eventTypeId: 1, + userId: users[2].id, + uid: "other-booking", + status: BookingStatus.ACCEPTED, + startTime: `${dateStringMinusOne}T05:00:00.000Z`, + endTime: `${dateStringMinusOne}T05:15:00.000Z`, + attendees: [ + getMockBookingAttendee({ + id: 5, + name: "attendee", + email: "attendee@test.com", + locale: "en", + timeZone: "Asia/Kolkata", + }), + ], + }, + ], + organizer: originalHost, + usersApartFromOrganizer: users.slice(1), + }) + ); + + await roundRobinReassignment({ + bookingId: 123, + orgId: null, + reassignedById: originalHost.id, + }); + + expect(eventManagerSpy).toBeCalledTimes(1); + + // Verify that updated emails were sent to all attendees + const updatedEmails = emails.get().filter((email) => { + const subject = email.subject || ""; + return subject.includes("updated") || subject.includes("event_type_has_been_updated"); + }); + + // Check that each attendee received an email + expect(updatedEmails.some((email) => email.to.includes(attendee1Email))).toBe(true); + expect(updatedEmails.some((email) => email.to.includes(attendee2Email))).toBe(true); + expect(updatedEmails.some((email) => email.to.includes(attendee3Email))).toBe(true); + + // Verify that each attendee's email does not contain other attendees' information + const attendee1EmailContent = updatedEmails.find((email) => email.to.includes(attendee1Email)); + if (attendee1EmailContent) { + const emailHtml = parse(attendee1EmailContent.html); + const emailText = emailHtml.innerText; + + // Should not contain other attendees' emails or names + expect(emailText).not.toContain(attendee2Email); + expect(emailText).not.toContain(attendee3Email); + expect(emailText).not.toContain("Attendee 2"); + expect(emailText).not.toContain("Attendee 3"); + // Should contain their own info + expect(emailText).toContain("Attendee 1"); + } + + const attendee2EmailContent = updatedEmails.find((email) => email.to.includes(attendee2Email)); + if (attendee2EmailContent) { + const emailHtml = parse(attendee2EmailContent.html); + const emailText = emailHtml.innerText; + + // Should not contain other attendees' emails or names + expect(emailText).not.toContain(attendee1Email); + expect(emailText).not.toContain(attendee3Email); + expect(emailText).not.toContain("Attendee 1"); + expect(emailText).not.toContain("Attendee 3"); + // Should contain their own info + expect(emailText).toContain("Attendee 2"); + } + + const attendee3EmailContent = updatedEmails.find((email) => email.to.includes(attendee3Email)); + if (attendee3EmailContent) { + const emailHtml = parse(attendee3EmailContent.html); + const emailText = emailHtml.innerText; + + // Should not contain other attendees' emails or names + expect(emailText).not.toContain(attendee1Email); + expect(emailText).not.toContain(attendee2Email); + expect(emailText).not.toContain("Attendee 1"); + expect(emailText).not.toContain("Attendee 2"); + // Should contain their own info + expect(emailText).toContain("Attendee 3"); + } + }); }); diff --git a/packages/features/ee/round-robin/roundRobinReassignment.ts b/packages/features/ee/round-robin/roundRobinReassignment.ts index d25acf9924..46792fa80a 100644 --- a/packages/features/ee/round-robin/roundRobinReassignment.ts +++ b/packages/features/ee/round-robin/roundRobinReassignment.ts @@ -341,6 +341,7 @@ export const roundRobinReassignment = async ({ location: bookingLocation, ...(platformClientParams ? platformClientParams : {}), organizationId: orgId, + seatsPerTimeSlot: eventType.seatsPerTimeSlot, }; if (hasOrganizerChanged) { @@ -497,6 +498,7 @@ export const roundRobinReassignment = async ({ await sendReassignedUpdatedEmailsAndSMS({ calEvent: evtWithoutCancellationReason, eventTypeMetadata: eventType?.metadata as EventTypeMetadata, + showAttendees: !!eventType.seatsShowAttendees, }); }