diff --git a/packages/emails/email-manager.test.ts b/packages/emails/email-manager.test.ts index 5cb96c8f8b..b5b6e4255a 100644 --- a/packages/emails/email-manager.test.ts +++ b/packages/emails/email-manager.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import type { EventTypeMetadata } from "@calcom/prisma/zod-utils"; +import type { CalendarEvent, Person } from "@calcom/types/Calendar"; import { shouldSkipAttendeeEmailWithSettings, fetchOrganizationEmailSettings } from "./email-manager"; +import AttendeeScheduledEmail from "./templates/attendee-scheduled-email"; const mockGetEmailSettings = vi.fn(); @@ -16,6 +18,36 @@ vi.mock("@calcom/prisma", () => ({ prisma: {}, })); +// Mock dependencies for AttendeeScheduledEmail tests +vi.mock("./lib/generateIcsFile", () => ({ + default: vi.fn(() => "mock-ical-content"), + GenerateIcsRole: { + ATTENDEE: "ATTENDEE", + }, +})); + +vi.mock("./src/renderEmail", () => ({ + default: vi.fn(() => Promise.resolve("mock-email")), +})); + +vi.mock("@calcom/lib/getReplyToHeader", () => ({ + getReplyToHeader: vi.fn(() => ({})), +})); + +vi.mock("@calcom/lib/CalEventParser", () => ({ + getRichDescription: vi.fn(() => "mock-description"), +})); + +vi.mock("./templates/_base-email", () => { + return { + default: class MockBaseEmail { + getMailerOptions() { + return { from: "test@cal.com" }; + } + }, + }; +}); + describe("shouldSkipAttendeeEmailWithSettings", () => { beforeEach(() => { vi.clearAllMocks(); @@ -150,3 +182,226 @@ describe("shouldSkipAttendeeEmailWithSettings", () => { }); }); }); + +describe("AttendeeScheduledEmail - Privacy fix for seated events", () => { + + const createMockPerson = (name: string, email: string): Person => ({ + name, + email, + timeZone: "America/New_York", + language: { + translate: vi.fn((key: string) => key), + locale: "en", + }, + }); + + const createMockCalendarEvent = ( + options: { + seatsPerTimeSlot?: number | null; + seatsShowAttendees?: boolean | null; + attendees?: Person[]; + } = {} + ): CalendarEvent => { + const attendees = options.attendees || [ + createMockPerson("Alice", "alice@example.com"), + createMockPerson("Bob", "bob@example.com"), + createMockPerson("Charlie", "charlie@example.com"), + ]; + + return { + title: "Test Event", + type: "Test Event Type", + startTime: "2024-01-01T10:00:00Z", + endTime: "2024-01-01T11:00:00Z", + organizer: createMockPerson("Organizer", "organizer@example.com"), + attendees, + seatsPerTimeSlot: options.seatsPerTimeSlot ?? null, + seatsShowAttendees: options.seatsShowAttendees ?? null, + } as CalendarEvent; + }; + + describe("Privacy: seatsShowAttendees setting", () => { + it("should filter attendees to only recipient when seatsShowAttendees is false for seated events", () => { + const calEvent = createMockCalendarEvent({ + seatsPerTimeSlot: 5, + seatsShowAttendees: false, + }); + const recipient = calEvent.attendees[0]; + + const email = new AttendeeScheduledEmail(calEvent, recipient); + + // Should only contain the recipient + expect(email.calEvent.attendees).toHaveLength(1); + expect(email.calEvent.attendees[0].email).toBe(recipient.email); + expect(email.calEvent.attendees[0].name).toBe(recipient.name); + }); + + it("should include all attendees when seatsShowAttendees is true for seated events", () => { + const calEvent = createMockCalendarEvent({ + seatsPerTimeSlot: 5, + seatsShowAttendees: true, + }); + const recipient = calEvent.attendees[0]; + + const email = new AttendeeScheduledEmail(calEvent, recipient); + + // Should contain all attendees + expect(email.calEvent.attendees).toHaveLength(3); + expect(email.calEvent.attendees.map((a) => a.email)).toEqual([ + "alice@example.com", + "bob@example.com", + "charlie@example.com", + ]); + }); + + it("should filter attendees when seatsShowAttendees is null for seated events (defaults to false)", () => { + const calEvent = createMockCalendarEvent({ + seatsPerTimeSlot: 5, + seatsShowAttendees: null, + }); + const recipient = calEvent.attendees[0]; + + const email = new AttendeeScheduledEmail(calEvent, recipient); + + // Should only contain the recipient (null defaults to false) + expect(email.calEvent.attendees).toHaveLength(1); + expect(email.calEvent.attendees[0].email).toBe(recipient.email); + }); + + it("should include all attendees for non-seated events regardless of seatsShowAttendees", () => { + const calEvent = createMockCalendarEvent({ + seatsPerTimeSlot: null, + seatsShowAttendees: false, // This shouldn't matter for non-seated events + }); + const recipient = calEvent.attendees[0]; + + const email = new AttendeeScheduledEmail(calEvent, recipient); + + // Should contain all attendees (non-seated events always show all) + expect(email.calEvent.attendees).toHaveLength(3); + }); + }); + + describe("Explicit showAttendees parameter", () => { + it("should use explicit showAttendees=true parameter even when seatsShowAttendees is false", () => { + const calEvent = createMockCalendarEvent({ + seatsPerTimeSlot: 5, + seatsShowAttendees: false, + }); + const recipient = calEvent.attendees[0]; + + const email = new AttendeeScheduledEmail(calEvent, recipient, true); + + // Should contain all attendees because explicit parameter overrides + expect(email.calEvent.attendees).toHaveLength(3); + }); + + it("should use explicit showAttendees=false parameter even when seatsShowAttendees is true", () => { + const calEvent = createMockCalendarEvent({ + seatsPerTimeSlot: 5, + seatsShowAttendees: true, + }); + const recipient = calEvent.attendees[0]; + + const email = new AttendeeScheduledEmail(calEvent, recipient, false); + + // Should only contain recipient because explicit parameter overrides + expect(email.calEvent.attendees).toHaveLength(1); + expect(email.calEvent.attendees[0].email).toBe(recipient.email); + }); + }); + + describe("Edge cases", () => { + it("should handle single attendee correctly when filtering", () => { + const calEvent = createMockCalendarEvent({ + seatsPerTimeSlot: 5, + seatsShowAttendees: false, + attendees: [createMockPerson("Solo", "solo@example.com")], + }); + const recipient = calEvent.attendees[0]; + + const email = new AttendeeScheduledEmail(calEvent, recipient); + + expect(email.calEvent.attendees).toHaveLength(1); + expect(email.calEvent.attendees[0].email).toBe("solo@example.com"); + }); + + it("should not mutate original calEvent when filtering attendees", () => { + const calEvent = createMockCalendarEvent({ + seatsPerTimeSlot: 5, + seatsShowAttendees: false, + }); + const originalAttendeesCount = calEvent.attendees.length; + const recipient = calEvent.attendees[0]; + + const email = new AttendeeScheduledEmail(calEvent, recipient); + + // Original calEvent should remain unchanged + expect(calEvent.attendees).toHaveLength(originalAttendeesCount); + // Email's calEvent should be filtered + expect(email.calEvent.attendees).toHaveLength(1); + // They should be different objects (cloned) + expect(email.calEvent).not.toBe(calEvent); + }); + + it("should use same calEvent reference when not filtering (performance optimization)", () => { + const calEvent = createMockCalendarEvent({ + seatsPerTimeSlot: 5, + seatsShowAttendees: true, + }); + const recipient = calEvent.attendees[0]; + + const email = new AttendeeScheduledEmail(calEvent, recipient); + + // Should use same reference when not filtering + expect(email.calEvent).toBe(calEvent); + }); + }); + + describe("Real-world scenarios", () => { + it("should protect privacy for paid seated events with sharing disabled", () => { + // This is the reported bug scenario + const calEvent = createMockCalendarEvent({ + seatsPerTimeSlot: 10, + seatsShowAttendees: false, // Privacy setting disabled + attendees: [ + createMockPerson("Customer 1", "customer1@example.com"), + createMockPerson("Customer 2", "customer2@example.com"), + createMockPerson("Customer 3", "customer3@example.com"), + ], + }); + const recipient = calEvent.attendees[1]; // Customer 2 + + const email = new AttendeeScheduledEmail(calEvent, recipient); + + // Customer 2 should only see their own information + expect(email.calEvent.attendees).toHaveLength(1); + expect(email.calEvent.attendees[0].email).toBe("customer2@example.com"); + expect(email.calEvent.attendees[0].name).toBe("Customer 2"); + // Should not contain other customers' information + expect(email.calEvent.attendees.some((a) => a.email === "customer1@example.com")).toBe(false); + expect(email.calEvent.attendees.some((a) => a.email === "customer3@example.com")).toBe(false); + }); + + it("should allow sharing when explicitly enabled for seated events", () => { + const calEvent = createMockCalendarEvent({ + seatsPerTimeSlot: 10, + seatsShowAttendees: true, // Sharing enabled + attendees: [ + createMockPerson("Attendee 1", "attendee1@example.com"), + createMockPerson("Attendee 2", "attendee2@example.com"), + ], + }); + const recipient = calEvent.attendees[0]; + + const email = new AttendeeScheduledEmail(calEvent, recipient); + + // Should see all attendees when sharing is enabled + expect(email.calEvent.attendees).toHaveLength(2); + expect(email.calEvent.attendees.map((a) => a.email)).toEqual([ + "attendee1@example.com", + "attendee2@example.com", + ]); + }); + }); +}); diff --git a/packages/emails/templates/attendee-scheduled-email.ts b/packages/emails/templates/attendee-scheduled-email.ts index 5e87742131..96c2c9373e 100644 --- a/packages/emails/templates/attendee-scheduled-email.ts +++ b/packages/emails/templates/attendee-scheduled-email.ts @@ -1,11 +1,9 @@ -import type { TFunction } from "i18next"; -import { default as cloneDeep } from "lodash/cloneDeep"; - import { getRichDescription } from "@calcom/lib/CalEventParser"; import { getReplyToHeader } from "@calcom/lib/getReplyToHeader"; import { TimeFormat } from "@calcom/lib/timeFormat"; import type { CalendarEvent, Person } from "@calcom/types/Calendar"; - +import type { TFunction } from "i18next"; +import { default as cloneDeep } from "lodash/cloneDeep"; import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile"; import renderEmail from "../src/renderEmail"; import BaseEmail from "./_base-email"; @@ -18,7 +16,16 @@ export default class AttendeeScheduledEmail extends BaseEmail { constructor(calEvent: CalendarEvent, attendee: Person, showAttendees?: boolean | undefined) { super(); - if (!showAttendees && calEvent.seatsPerTimeSlot) { + let shouldShowAttendees: boolean; + if (showAttendees !== undefined) { + shouldShowAttendees = showAttendees; + } else if (calEvent.seatsPerTimeSlot) { + shouldShowAttendees = calEvent.seatsShowAttendees ?? false; + } else { + shouldShowAttendees = true; + } + + if (!shouldShowAttendees && calEvent.seatsPerTimeSlot) { this.calEvent = cloneDeep(calEvent); this.calEvent.attendees = [attendee]; } else { @@ -63,8 +70,8 @@ ${this.t( title ? title : this.calEvent.recurringEvent?.count - ? "your_event_has_been_scheduled_recurring" - : "your_event_has_been_scheduled" + ? "your_event_has_been_scheduled_recurring" + : "your_event_has_been_scheduled" )} ${this.t(subtitle)} diff --git a/packages/features/bookings/lib/payment/getBooking.ts b/packages/features/bookings/lib/payment/getBooking.ts index 25d5a035b6..b63150cdf6 100644 --- a/packages/features/bookings/lib/payment/getBooking.ts +++ b/packages/features/bookings/lib/payment/getBooking.ts @@ -88,6 +88,8 @@ export async function getBooking(bookingId: number) { parentId: true, }, }, + seatsPerTimeSlot: true, + seatsShowAttendees: true, }, }, metadata: true, @@ -196,6 +198,8 @@ export async function getBooking(bookingId: number) { destinationCalendar: selectedDestinationCalendar ? [selectedDestinationCalendar] : [], recurringEvent: parseRecurringEvent(eventType?.recurringEvent), customReplyToEmail: booking.eventType?.customReplyToEmail, + seatsPerTimeSlot: booking.eventType?.seatsPerTimeSlot, + seatsShowAttendees: booking.eventType?.seatsShowAttendees, }; return { diff --git a/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts b/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts index f2887c4f6e..70f544e96f 100644 --- a/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts @@ -151,6 +151,8 @@ export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => { hideCalendarEventDetails: true, disableGuests: true, customReplyToEmail: true, + seatsPerTimeSlot: true, + seatsShowAttendees: true, metadata: true, locations: true, team: { @@ -322,6 +324,8 @@ export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => { hideCalendarEventDetails: booking.eventType?.hideCalendarEventDetails, eventTypeId: booking.eventType?.id, customReplyToEmail: booking.eventType?.customReplyToEmail, + seatsPerTimeSlot: booking.eventType?.seatsPerTimeSlot, + seatsShowAttendees: booking.eventType?.seatsShowAttendees, team: booking.eventType?.team ? { name: booking.eventType.team.name,