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:
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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user