diff --git a/packages/features/CalendarEventBuilder.test.ts b/packages/features/CalendarEventBuilder.test.ts index cd12f0f26e..4bede68260 100644 --- a/packages/features/CalendarEventBuilder.test.ts +++ b/packages/features/CalendarEventBuilder.test.ts @@ -980,6 +980,9 @@ describe("CalendarEventBuilder", () => { }); it("should create a calendar event from booking with team", async () => { + // Note: The CalendarEventBuilder filters team members to only include hosts + // whose emails appear in booking.attendees. This simulates a COLLECTIVE event + // where all hosts are assigned to the booking. const mockBooking = { uid: "booking-789", metadata: null, @@ -1001,6 +1004,14 @@ describe("CalendarEventBuilder", () => { locale: "en", phoneNumber: null, }, + { + // Team member host - included in attendees for COLLECTIVE events + name: "Team Member", + email: "member@example.com", + timeZone: "America/Los_Angeles", + locale: "en", + phoneNumber: null, + }, ], user: { id: 3, @@ -1647,6 +1658,14 @@ describe("CalendarEventBuilder", () => { locale: "en", phoneNumber: null, }, + { + // Team member host - included in attendees for COLLECTIVE events + name: "Team Member", + email: "member@example.com", + timeZone: "America/Los_Angeles", + locale: "en", + phoneNumber: null, + }, ], user: { id: 100, @@ -1836,12 +1855,14 @@ describe("CalendarEventBuilder", () => { expect(builtFromBooking.organizer.username).toBe("teamlead"); expect(builtFromBooking.organizer.timeZone).toBe("America/New_York"); - expect(builtFromBooking.attendees).toHaveLength(2); + expect(builtFromBooking.attendees).toHaveLength(3); expect(builtFromBooking.attendees[0].name).toBe("Complete User"); expect(builtFromBooking.attendees[0].email).toBe("complete@example.com"); expect(builtFromBooking.attendees[0].timeZone).toBe("America/New_York"); expect(builtFromBooking.attendees[1].name).toBe("Guest User"); expect(builtFromBooking.attendees[1].email).toBe("guest@example.com"); + expect(builtFromBooking.attendees[2].name).toBe("Team Member"); + expect(builtFromBooking.attendees[2].email).toBe("member@example.com"); expect(builtFromBooking.team).toBeDefined(); expect(builtFromBooking.team?.id).toBe(50); diff --git a/packages/features/CalendarEventBuilder.ts b/packages/features/CalendarEventBuilder.ts index 9ec0e6346a..492b1aed7d 100644 --- a/packages/features/CalendarEventBuilder.ts +++ b/packages/features/CalendarEventBuilder.ts @@ -7,7 +7,7 @@ import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent"; import { getTranslation } from "@calcom/lib/server/i18n"; import { getTimeFormatStringFromUserTimeFormat, type TimeFormat } from "@calcom/lib/timeFormat"; import type { Attendee, BookingSeat, DestinationCalendar, Prisma, User } from "@calcom/prisma/client"; -import type { SchedulingType } from "@calcom/prisma/enums"; +import { SchedulingType } from "@calcom/prisma/enums"; import { bookingResponses as bookingResponsesSchema } from "@calcom/prisma/zod-utils"; import type { CalendarEvent, Person, CalEventResponses, AppsStatus } from "@calcom/types/Calendar"; import type { VideoCallData } from "@calcom/types/VideoApiAdapter"; @@ -226,12 +226,25 @@ export class CalendarEventBuilder { // Team & calendars if (eventType.team) { + // We need to get the team members assigned to the booking + // In the DB team members are stored in the Attendee table + const bookingAttendees = booking.attendees; + + const hostsToInclude = eventType.hosts.filter((host) => + bookingAttendees.some((attendee) => attendee.email === host.user.email) + ); + + const hostsWithoutOrganizerData = hostsToInclude.filter( + (host) => host.user.email !== user.email + ); + const hostsWithoutOrganizer = await Promise.all( - eventType.hosts.filter((h) => h.user.email !== user.email).map((h) => _buildPersonFromUser(h.user)) + hostsWithoutOrganizerData.map((host) => _buildPersonFromUser(host.user)) ); const hostCalendars = [ - ...eventType.hosts.map((h) => h.user.destinationCalendar).filter(Boolean), + user.destinationCalendar, + ...hostsWithoutOrganizerData.map((h) => h.user.destinationCalendar).filter(Boolean), user.destinationCalendar, ].filter(Boolean) as NonNullable[]; diff --git a/packages/features/ee/workflows/lib/service/EmailWorkflowService.test.ts b/packages/features/ee/workflows/lib/service/EmailWorkflowService.test.ts index 64fd338591..b11f5d7cc1 100644 --- a/packages/features/ee/workflows/lib/service/EmailWorkflowService.test.ts +++ b/packages/features/ee/workflows/lib/service/EmailWorkflowService.test.ts @@ -2,9 +2,17 @@ import { describe, expect, vi, beforeEach, test } from "vitest"; import type { BookingSeatRepository } from "@calcom/features/bookings/repositories/BookingSeatRepository"; import type { WorkflowReminderRepository } from "@calcom/features/ee/workflows/repositories/WorkflowReminderRepository"; -import { WorkflowActions, WorkflowTemplates, WorkflowTriggerEvents } from "@calcom/prisma/enums"; +import { + SchedulingType, + TimeUnit, + WorkflowActions, + WorkflowTemplates, + WorkflowTriggerEvents, +} from "@calcom/prisma/enums"; import type { CalendarEvent } from "@calcom/types/Calendar"; +import { EmailWorkflowService } from "./EmailWorkflowService"; + vi.mock("@calcom/emails/workflow-email-service", () => ({ sendCustomWorkflowEmail: vi.fn(), })); @@ -22,8 +30,6 @@ vi.mock("@calcom/prisma", () => ({ prisma: {}, })); -import { EmailWorkflowService } from "./EmailWorkflowService"; - const mockWorkflowReminderRepository: Pick = { findByIdIncludeStepAndWorkflow: vi.fn(), }; @@ -156,4 +162,223 @@ describe("EmailWorkflowService", () => { expect(mockBookingSeatRepository.getByUidIncludeAttendee).toHaveBeenCalledWith("seat-123"); }); }); + + describe("generateParametersToBuildEmailWorkflowContent - EMAIL_HOST", () => { + const mockCommonScheduleFunctionParams = { + triggerEvent: WorkflowTriggerEvents.BEFORE_EVENT, + timeSpan: { + time: 24, + timeUnit: TimeUnit.HOUR, + }, + workflowStepId: 1, + template: WorkflowTemplates.REMINDER, + userId: 1, + teamId: null, + seatReferenceUid: undefined, + verifiedAt: new Date(), + creditCheckFn: vi.fn().mockResolvedValue(true), + }; + + const baseMockEvt: Partial = { + uid: "booking-123", + bookerUrl: "https://cal.com", + title: "Test Meeting", + startTime: "2024-12-01T10:00:00Z", + endTime: "2024-12-01T11:00:00Z", + organizer: { + name: "Organizer Name", + email: "organizer@example.com", + timeZone: "UTC", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + language: { locale: "en", translate: (() => "") as any }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + timeFormat: "h:mma" as any, + }, + attendees: [ + { + name: "Attendee Name", + email: "attendee@example.com", + timeZone: "UTC", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + language: { locale: "en", translate: (() => "") as any }, + }, + ], + }; + + const mockWorkflowStep = { + id: 1, + action: WorkflowActions.EMAIL_HOST, + verifiedAt: new Date(), + sendTo: null, + template: WorkflowTemplates.REMINDER, + reminderBody: null, + emailSubject: null, + sender: null, + includeCalendarEvent: false, + numberVerificationPending: false, + numberRequired: false, + }; + + test("should send to organizer and team members for ROUND_ROBIN scheduling type", async () => { + // Note: For ROUND_ROBIN, the CalendarEventBuilder filters team members to only include + // those assigned to the booking. EmailWorkflowService sends to all team members in evt.team.members. + const mockEvt: Partial = { + ...baseMockEvt, + schedulingType: SchedulingType.ROUND_ROBIN, + team: { + id: 1, + name: "Test Team", + members: [ + { + id: 1, + name: "Team Member 1", + email: "team1@example.com", + timeZone: "UTC", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + language: { locale: "en", translate: (() => "") as any }, + }, + { + id: 2, + name: "Team Member 2", + email: "team2@example.com", + timeZone: "UTC", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + language: { locale: "en", translate: (() => "") as any }, + }, + ], + }, + }; + + const result = await emailWorkflowService.generateParametersToBuildEmailWorkflowContent({ + evt: mockEvt as CalendarEvent, + workflowStep: mockWorkflowStep, + workflow: { userId: 1 }, + commonScheduleFunctionParams: mockCommonScheduleFunctionParams, + hideBranding: false, + }); + + // EmailWorkflowService sends to organizer + all team members in evt.team.members + // The filtering of team members happens in CalendarEventBuilder, not here + expect(result.sendTo).toContain("organizer@example.com"); + expect(result.sendTo).toContain("team1@example.com"); + expect(result.sendTo).toContain("team2@example.com"); + expect(result.sendTo.length).toBe(3); + }); + + test("should send to organizer and team members for COLLECTIVE scheduling type", async () => { + const mockEvt: Partial = { + ...baseMockEvt, + schedulingType: SchedulingType.COLLECTIVE, + team: { + id: 1, + name: "Test Team", + members: [ + { + id: 1, + name: "Team Member 1", + email: "team1@example.com", + timeZone: "UTC", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + language: { locale: "en", translate: (() => "") as any }, + }, + { + id: 2, + name: "Team Member 2", + email: "team2@example.com", + timeZone: "UTC", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + language: { locale: "en", translate: (() => "") as any }, + }, + ], + }, + }; + + const result = await emailWorkflowService.generateParametersToBuildEmailWorkflowContent({ + evt: mockEvt as CalendarEvent, + workflowStep: mockWorkflowStep, + workflow: { userId: 1 }, + commonScheduleFunctionParams: mockCommonScheduleFunctionParams, + hideBranding: false, + }); + + expect(result.sendTo).toContain("organizer@example.com"); + expect(result.sendTo).toContain("team1@example.com"); + expect(result.sendTo).toContain("team2@example.com"); + expect(result.sendTo.length).toBe(3); + }); + + test("should send to organizer only when team is undefined for COLLECTIVE", async () => { + const mockEvt: Partial = { + ...baseMockEvt, + schedulingType: SchedulingType.COLLECTIVE, + team: undefined, + } as Partial; + + const result = await emailWorkflowService.generateParametersToBuildEmailWorkflowContent({ + evt: mockEvt as CalendarEvent, + workflowStep: mockWorkflowStep, + workflow: { userId: 1 }, + commonScheduleFunctionParams: mockCommonScheduleFunctionParams, + hideBranding: false, + }); + + expect(result.sendTo).toEqual(["organizer@example.com"]); + expect(result.sendTo.length).toBe(1); + }); + + test("should send to organizer only when team members array is empty for COLLECTIVE", async () => { + const mockEvt: Partial = { + ...baseMockEvt, + schedulingType: SchedulingType.COLLECTIVE, + team: { + id: 1, + name: "Test Team", + members: [], + }, + }; + + const result = await emailWorkflowService.generateParametersToBuildEmailWorkflowContent({ + evt: mockEvt as CalendarEvent, + workflowStep: mockWorkflowStep, + workflow: { userId: 1 }, + commonScheduleFunctionParams: mockCommonScheduleFunctionParams, + hideBranding: false, + }); + + expect(result.sendTo).toEqual(["organizer@example.com"]); + expect(result.sendTo.length).toBe(1); + }); + + test("should send to organizer only for other scheduling types (e.g., null)", async () => { + const mockEvt: Partial = { + ...baseMockEvt, + schedulingType: null, + team: { + id: 1, + name: "Test Team", + members: [ + { + id: 1, + name: "Team Member 1", + email: "team1@example.com", + timeZone: "UTC", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + language: { locale: "en", translate: (() => "") as any }, + }, + ], + }, + } as Partial; + + const result = await emailWorkflowService.generateParametersToBuildEmailWorkflowContent({ + evt: mockEvt as CalendarEvent, + workflowStep: mockWorkflowStep, + workflow: { userId: 1 }, + commonScheduleFunctionParams: mockCommonScheduleFunctionParams, + hideBranding: false, + }); + + expect(result.sendTo).toEqual(["organizer@example.com"]); + expect(result.sendTo.length).toBe(1); + }); + }); });