fix: workflow reminder send to all RR hosts. (#25597)

* fix: wrkflow reminder issue

* Update EmailWorkflowService.ts

* update

* Refactor team member email extraction for clarity

* add tests

* Undo changes in EmailWorkflowService

* Get team members assigned to the booking

* fix: Update test to match EmailWorkflowService behavior for ROUND_ROBIN

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: Update CalendarEventBuilder tests to include host emails in attendees

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Apply suggestion from @cubic-dev-ai[bot]

Filter organizer our of `hostsToInclude`

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Only include organizer and host destination calendar

* Type fix

* Type fix

---------

Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
Anik Dhabal Babu
2025-12-04 09:21:48 +02:00
committed by GitHub
co-authored by Joe Au-Yeung Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Joe Au-Yeung cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
parent 843329ccce
commit d00cf329e8
3 changed files with 266 additions and 7 deletions
+22 -1
View File
@@ -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);
+16 -3
View File
@@ -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<DestinationCalendar>[];
@@ -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<WorkflowReminderRepository, "findByIdIncludeStepAndWorkflow"> = {
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<CalendarEvent> = {
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<CalendarEvent> = {
...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<CalendarEvent> = {
...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<CalendarEvent> = {
...baseMockEvt,
schedulingType: SchedulingType.COLLECTIVE,
team: undefined,
} as Partial<CalendarEvent>;
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<CalendarEvent> = {
...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<CalendarEvent> = {
...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<CalendarEvent>;
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);
});
});
});