Files
calendar/packages/features/ee/workflows/lib/service/EmailWorkflowService.test.ts
T
MorganGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
592cb4fb7b feat: add platform URL support for reschedule and cancel links in workflow emails (#27132)
* feat: add platform URL support for reschedule and cancel links in workflow emails

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* feat: pass platform URL data to CalendarEventBuilder in workflow emails

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* Revert "feat: pass platform URL data to CalendarEventBuilder in workflow emails"

This reverts commit 1d4d3623c93cd4eeeef18ffdad0597fe583b6a55.

* chore: provide platform metadat to workflow email task

* fixup! chore: provide platform metadat to workflow email task

* test: add unit tests for platform URL handling in EmailWorkflowService

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* test: update WorkflowService tests to include platform params in tasker payload

Co-Authored-By: morgan@cal.com <morgan@cal.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-03-13 15:48:51 +04:00

1179 lines
41 KiB
TypeScript

import type { BookingSeatRepository } from "@calcom/features/bookings/repositories/BookingSeatRepository";
import type { WorkflowReminderRepository } from "@calcom/features/ee/workflows/repositories/WorkflowReminderRepository";
import { TimeFormat } from "@calcom/lib/timeFormat";
import {
SchedulingType,
TimeUnit,
WorkflowActions,
WorkflowTemplates,
WorkflowTriggerEvents,
} from "@calcom/prisma/enums";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { EmailWorkflowService } from "./EmailWorkflowService";
vi.mock("@calcom/emails/workflow-email-service", () => ({
sendCustomWorkflowEmail: vi.fn(),
}));
vi.mock("@calcom/features/profile/lib/hideBranding", () => ({
getHideBranding: vi.fn().mockResolvedValue(false),
}));
vi.mock("@calcom/i18n/server", () => ({
getTranslation: vi.fn().mockResolvedValue((key: string) => key),
}));
vi.mock("@calcom/prisma", () => ({
default: {},
prisma: {},
}));
vi.mock("@calcom/emails/lib/generateIcsString", () => ({
default: vi.fn().mockReturnValue("mock-ics-content"),
}));
const mockTranslationService = vi.hoisted(() => ({
getWorkflowStepTranslation: vi.fn(),
}));
vi.mock("@calcom/features/di/containers/TranslationService", () => ({
getTranslationService: vi.fn().mockResolvedValue(mockTranslationService),
}));
vi.mock("@calcom/lib/constants", async () => {
const actual = await vi.importActual<typeof import("@calcom/lib/constants")>("@calcom/lib/constants");
return {
...actual,
WEBAPP_URL: "https://cal.com",
APP_NAME: "Cal.com",
};
});
vi.mock("short-uuid", () => ({
__esModule: true,
default: () => ({ fromUUID: (uid: string) => uid }),
}));
const mockWorkflowReminderRepository: Pick<WorkflowReminderRepository, "findByIdIncludeStepAndWorkflow"> = {
findByIdIncludeStepAndWorkflow: vi.fn(),
};
const mockBookingSeatRepository: Pick<
BookingSeatRepository,
"getByUidIncludeAttendee" | "getByReferenceUidWithAttendeeDetails"
> = {
getByUidIncludeAttendee: vi.fn(),
getByReferenceUidWithAttendeeDetails: vi.fn(),
};
describe("EmailWorkflowService", () => {
let emailWorkflowService: EmailWorkflowService;
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(mockTranslationService.getWorkflowStepTranslation).mockReset();
emailWorkflowService = new EmailWorkflowService(
mockWorkflowReminderRepository as WorkflowReminderRepository,
mockBookingSeatRepository as BookingSeatRepository
);
});
describe("handleSendEmailWorkflowTask", () => {
const mockEvt: 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",
language: { locale: "en", translate: (key: string) => key },
timeFormat: 12,
},
attendees: [
{
name: "Attendee Name",
email: "attendee@example.com",
timeZone: "UTC",
language: { locale: "en", translate: (key: string) => key },
},
],
};
test("should throw error if workflow reminder not found", async () => {
vi.mocked(mockWorkflowReminderRepository.findByIdIncludeStepAndWorkflow).mockResolvedValue(null);
await expect(
emailWorkflowService.handleSendEmailWorkflowTask({
evt: mockEvt as CalendarEvent,
workflowReminderId: 1,
})
).rejects.toThrow("Workflow reminder not found with id 1");
});
test("should throw error if workflow step not verified", async () => {
vi.mocked(mockWorkflowReminderRepository.findByIdIncludeStepAndWorkflow).mockResolvedValue({
id: 1,
workflowStep: {
id: 1,
verifiedAt: null,
action: WorkflowActions.EMAIL_ATTENDEE,
workflow: { userId: 1, teamId: null },
},
});
await expect(
emailWorkflowService.handleSendEmailWorkflowTask({
evt: mockEvt as CalendarEvent,
workflowReminderId: 1,
})
).rejects.toThrow("Workflow step id 1 is not verified");
});
test("should throw error if workflow step not found on reminder", async () => {
vi.mocked(mockWorkflowReminderRepository.findByIdIncludeStepAndWorkflow).mockResolvedValue({
id: 1,
workflowStep: null,
});
await expect(
emailWorkflowService.handleSendEmailWorkflowTask({
evt: mockEvt as CalendarEvent,
workflowReminderId: 1,
})
).rejects.toThrow("Workflow step not found on reminder with id 1");
});
test("should fetch seat attendee email for seated events", async () => {
const mockWorkflowReminder = {
id: 1,
seatReferenceId: "seat-123",
workflowStep: {
id: 1,
action: WorkflowActions.EMAIL_ATTENDEE,
sendTo: null,
template: WorkflowTemplates.REMINDER,
reminderBody: null,
emailSubject: null,
sender: null,
includeCalendarEvent: false,
verifiedAt: new Date(),
workflow: {
userId: 1,
teamId: null,
trigger: WorkflowTriggerEvents.BEFORE_EVENT,
time: 24,
timeUnit: "HOUR",
},
},
};
vi.mocked(mockWorkflowReminderRepository.findByIdIncludeStepAndWorkflow).mockResolvedValue(
mockWorkflowReminder
);
vi.mocked(mockBookingSeatRepository.getByUidIncludeAttendee).mockResolvedValue({
attendee: {
email: "seat-attendee@example.com",
},
});
try {
await emailWorkflowService.handleSendEmailWorkflowTask({
evt: mockEvt as CalendarEvent,
workflowReminderId: 1,
});
} catch {
// Expected to throw due to incomplete mock setup - we only care about the seat lookup
}
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);
});
});
describe("generateParametersToBuildEmailWorkflowContent - Form Data", () => {
const mockCommonScheduleFunctionParams = {
triggerEvent: WorkflowTriggerEvents.FORM_SUBMITTED,
timeSpan: {
time: null,
timeUnit: null,
},
workflowStepId: 1,
template: WorkflowTemplates.REMINDER,
userId: 1,
teamId: null,
seatReferenceUid: undefined,
verifiedAt: new Date(),
creditCheckFn: vi.fn().mockResolvedValue(true),
};
const mockFormData = {
responses: {
"contact person": {
value: "Jane Smith",
response: "Jane Smith",
},
email: {
value: "jane@example.com",
response: "jane@example.com",
},
},
routedEventTypeId: null,
user: {
email: "user@test.com",
timeFormat: 12,
locale: "en",
},
};
test("should work with EMAIL_ADDRESS action and formData (no evt)", async () => {
const mockWorkflowStep = {
id: 1,
action: WorkflowActions.EMAIL_ADDRESS,
verifiedAt: new Date(),
sendTo: "recipient@example.com",
template: WorkflowTemplates.REMINDER,
reminderBody: null,
emailSubject: null,
sender: null,
includeCalendarEvent: false,
numberVerificationPending: false,
numberRequired: false,
};
const result = await emailWorkflowService.generateParametersToBuildEmailWorkflowContent({
formData: mockFormData,
workflowStep: mockWorkflowStep,
workflow: { userId: 1 },
commonScheduleFunctionParams: mockCommonScheduleFunctionParams,
hideBranding: false,
});
expect(result.sendTo).toEqual(["recipient@example.com"]);
expect(result.formData).toEqual(mockFormData);
expect(result.evt).toBeUndefined();
});
test("should work with EMAIL_ATTENDEE action and formData (no evt)", async () => {
const mockWorkflowStep = {
id: 1,
action: WorkflowActions.EMAIL_ATTENDEE,
verifiedAt: new Date(),
sendTo: null,
template: WorkflowTemplates.REMINDER,
reminderBody: null,
emailSubject: null,
sender: null,
includeCalendarEvent: false,
numberVerificationPending: false,
numberRequired: false,
};
const result = await emailWorkflowService.generateParametersToBuildEmailWorkflowContent({
formData: mockFormData,
workflowStep: mockWorkflowStep,
workflow: { userId: 1 },
commonScheduleFunctionParams: mockCommonScheduleFunctionParams,
hideBranding: false,
});
// Should extract email from formData responses
expect(result.sendTo).toEqual(["jane@example.com"]);
expect(result.formData).toEqual(mockFormData);
expect(result.evt).toBeUndefined();
});
test("should throw error if neither evt nor formData is provided", async () => {
const mockWorkflowStep = {
id: 1,
action: WorkflowActions.EMAIL_ADDRESS,
verifiedAt: new Date(),
sendTo: "recipient@example.com",
template: WorkflowTemplates.REMINDER,
reminderBody: null,
emailSubject: null,
sender: null,
includeCalendarEvent: false,
numberVerificationPending: false,
numberRequired: false,
};
await expect(
emailWorkflowService.generateParametersToBuildEmailWorkflowContent({
workflowStep: mockWorkflowStep,
workflow: { userId: 1 },
commonScheduleFunctionParams: mockCommonScheduleFunctionParams,
hideBranding: false,
})
).rejects.toThrow("Either evt or formData must be provided");
});
});
describe("generateEmailPayloadForEvtWorkflow - ICS attachment", () => {
const mockBookingInfo = {
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",
language: { locale: "en" },
timeFormat: "h:mma",
},
attendees: [
{
name: "Attendee Name",
email: "attendee@example.com",
timeZone: "UTC",
language: { locale: "en" },
},
],
};
test("should NOT include ICS attachment for BOOKING_REQUESTED trigger even when includeCalendarEvent is true", async () => {
const result = await emailWorkflowService.generateEmailPayloadForEvtWorkflow({
evt: mockBookingInfo,
sendTo: ["attendee@example.com"],
hideBranding: false,
emailSubject: "Test Subject",
emailBody: "Test Body",
sender: "Cal.com",
action: WorkflowActions.EMAIL_ATTENDEE,
template: WorkflowTemplates.REMINDER,
includeCalendarEvent: true,
triggerEvent: WorkflowTriggerEvents.BOOKING_REQUESTED,
});
expect(result.attachments).toBeUndefined();
});
test("should include ICS attachment for BEFORE_EVENT trigger when includeCalendarEvent is true", async () => {
const result = await emailWorkflowService.generateEmailPayloadForEvtWorkflow({
evt: mockBookingInfo,
sendTo: ["attendee@example.com"],
hideBranding: false,
emailSubject: "Test Subject",
emailBody: "Test Body",
sender: "Cal.com",
action: WorkflowActions.EMAIL_ATTENDEE,
template: WorkflowTemplates.REMINDER,
includeCalendarEvent: true,
triggerEvent: WorkflowTriggerEvents.BEFORE_EVENT,
});
expect(result.attachments).toBeDefined();
expect(result.attachments).toHaveLength(1);
expect(result.attachments?.[0].filename).toBe("event.ics");
expect(result.attachments?.[0].contentType).toBe("text/calendar; charset=UTF-8; method=REQUEST");
});
test("should NOT include ICS attachment when includeCalendarEvent is false", async () => {
const result = await emailWorkflowService.generateEmailPayloadForEvtWorkflow({
evt: mockBookingInfo,
sendTo: ["attendee@example.com"],
hideBranding: false,
emailSubject: "Test Subject",
emailBody: "Test Body",
sender: "Cal.com",
action: WorkflowActions.EMAIL_ATTENDEE,
template: WorkflowTemplates.REMINDER,
includeCalendarEvent: false,
triggerEvent: WorkflowTriggerEvents.BEFORE_EVENT,
});
expect(result.attachments).toBeUndefined();
});
});
describe("generateEmailPayloadForEvtWorkflow - Platform URL handling", () => {
const baseBookingInfo = {
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",
language: { locale: "en" },
timeFormat: "h:mma",
username: "organizer-user",
},
attendees: [
{
name: "Attendee Name",
email: "attendee@example.com",
timeZone: "UTC",
language: { locale: "en" },
},
],
eventType: {
slug: "test-event",
recurringEvent: null,
},
};
test("should use platform cancel URL when platformClientId and platformCancelUrl are provided", async () => {
const mockBookingInfoWithPlatform = {
...baseBookingInfo,
platformClientId: "platform-client-123",
platformCancelUrl: "https://platform.example.com/cancel",
platformRescheduleUrl: "https://platform.example.com/reschedule",
};
const result = await emailWorkflowService.generateEmailPayloadForEvtWorkflow({
evt: mockBookingInfoWithPlatform,
sendTo: ["attendee@example.com"],
hideBranding: false,
emailSubject: "Test Subject",
emailBody: "Cancel here: {CANCEL_URL}",
sender: "Cal.com",
action: WorkflowActions.EMAIL_ATTENDEE,
template: WorkflowTemplates.CUSTOM,
includeCalendarEvent: false,
triggerEvent: WorkflowTriggerEvents.BEFORE_EVENT,
});
// The cancel link should use the platform URL
expect(result.html).toContain("https://platform.example.com/cancel/booking-123");
expect(result.html).toContain("slug=test-event");
expect(result.html).toContain("username=organizer-user");
expect(result.html).toContain("cancel=true");
});
test("should use platform reschedule URL when platformClientId and platformRescheduleUrl are provided", async () => {
const mockBookingInfoWithPlatform = {
...baseBookingInfo,
platformClientId: "platform-client-123",
platformCancelUrl: "https://platform.example.com/cancel",
platformRescheduleUrl: "https://platform.example.com/reschedule",
};
const result = await emailWorkflowService.generateEmailPayloadForEvtWorkflow({
evt: mockBookingInfoWithPlatform,
sendTo: ["attendee@example.com"],
hideBranding: false,
emailSubject: "Test Subject",
emailBody: "Reschedule here: {RESCHEDULE_URL}",
sender: "Cal.com",
action: WorkflowActions.EMAIL_ATTENDEE,
template: WorkflowTemplates.CUSTOM,
includeCalendarEvent: false,
triggerEvent: WorkflowTriggerEvents.BEFORE_EVENT,
});
// The reschedule link should use the platform URL
expect(result.html).toContain("https://platform.example.com/reschedule/booking-123");
expect(result.html).toContain("slug=test-event");
expect(result.html).toContain("username=organizer-user");
expect(result.html).toContain("reschedule=true");
});
test("should use standard bookerUrl when platformClientId is not provided", async () => {
const mockBookingInfoWithoutPlatform = {
...baseBookingInfo,
platformClientId: null,
platformCancelUrl: null,
platformRescheduleUrl: null,
};
const result = await emailWorkflowService.generateEmailPayloadForEvtWorkflow({
evt: mockBookingInfoWithoutPlatform,
sendTo: ["attendee@example.com"],
hideBranding: false,
emailSubject: "Test Subject",
emailBody: "Cancel: {CANCEL_URL} Reschedule: {RESCHEDULE_URL}",
sender: "Cal.com",
action: WorkflowActions.EMAIL_ATTENDEE,
template: WorkflowTemplates.CUSTOM,
includeCalendarEvent: false,
triggerEvent: WorkflowTriggerEvents.BEFORE_EVENT,
});
// Should use standard bookerUrl pattern
expect(result.html).toContain("https://cal.com/booking/booking-123?cancel=true");
expect(result.html).toContain("https://cal.com/reschedule/booking-123");
// Should NOT contain platform URLs
expect(result.html).not.toContain("platform.example.com");
});
test("should include teamId in platform cancel URL when team is provided", async () => {
const mockBookingInfoWithTeam = {
...baseBookingInfo,
platformClientId: "platform-client-123",
platformCancelUrl: "https://platform.example.com/cancel",
platformRescheduleUrl: "https://platform.example.com/reschedule",
team: {
id: 42,
name: "Test Team",
members: [],
},
};
const result = await emailWorkflowService.generateEmailPayloadForEvtWorkflow({
evt: mockBookingInfoWithTeam,
sendTo: ["attendee@example.com"],
hideBranding: false,
emailSubject: "Test Subject",
emailBody: "Cancel here: {CANCEL_URL}",
sender: "Cal.com",
action: WorkflowActions.EMAIL_ATTENDEE,
template: WorkflowTemplates.CUSTOM,
includeCalendarEvent: false,
triggerEvent: WorkflowTriggerEvents.BEFORE_EVENT,
});
expect(result.html).toContain("teamId=42");
});
test("should include teamId in platform reschedule URL when team is provided", async () => {
const mockBookingInfoWithTeam = {
...baseBookingInfo,
platformClientId: "platform-client-123",
platformCancelUrl: "https://platform.example.com/cancel",
platformRescheduleUrl: "https://platform.example.com/reschedule",
team: {
id: 42,
name: "Test Team",
members: [],
},
};
const result = await emailWorkflowService.generateEmailPayloadForEvtWorkflow({
evt: mockBookingInfoWithTeam,
sendTo: ["attendee@example.com"],
hideBranding: false,
emailSubject: "Test Subject",
emailBody: "Reschedule here: {RESCHEDULE_URL}",
sender: "Cal.com",
action: WorkflowActions.EMAIL_ATTENDEE,
template: WorkflowTemplates.CUSTOM,
includeCalendarEvent: false,
triggerEvent: WorkflowTriggerEvents.BEFORE_EVENT,
});
expect(result.html).toContain("teamId=42");
});
test("should include seatReferenceUid in platform cancel URL for seated events", async () => {
const mockBookingInfoWithPlatform = {
...baseBookingInfo,
platformClientId: "platform-client-123",
platformCancelUrl: "https://platform.example.com/cancel",
platformRescheduleUrl: "https://platform.example.com/reschedule",
};
const result = await emailWorkflowService.generateEmailPayloadForEvtWorkflow({
evt: mockBookingInfoWithPlatform,
sendTo: ["attendee@example.com"],
seatReferenceUid: "seat-ref-456",
hideBranding: false,
emailSubject: "Test Subject",
emailBody: "Cancel here: {CANCEL_URL}",
sender: "Cal.com",
action: WorkflowActions.EMAIL_ATTENDEE,
template: WorkflowTemplates.CUSTOM,
includeCalendarEvent: false,
triggerEvent: WorkflowTriggerEvents.BEFORE_EVENT,
});
expect(result.html).toContain("seatReferenceUid=seat-ref-456");
});
test("should use seatReferenceUid as uid in platform reschedule URL for seated events", async () => {
const mockBookingInfoWithPlatform = {
...baseBookingInfo,
platformClientId: "platform-client-123",
platformCancelUrl: "https://platform.example.com/cancel",
platformRescheduleUrl: "https://platform.example.com/reschedule",
};
const result = await emailWorkflowService.generateEmailPayloadForEvtWorkflow({
evt: mockBookingInfoWithPlatform,
sendTo: ["attendee@example.com"],
seatReferenceUid: "seat-ref-456",
hideBranding: false,
emailSubject: "Test Subject",
emailBody: "Reschedule here: {RESCHEDULE_URL}",
sender: "Cal.com",
action: WorkflowActions.EMAIL_ATTENDEE,
template: WorkflowTemplates.CUSTOM,
includeCalendarEvent: false,
triggerEvent: WorkflowTriggerEvents.BEFORE_EVENT,
});
// For seated events with EMAIL_ATTENDEE action, the reschedule URL should use seatReferenceUid
expect(result.html).toContain("https://platform.example.com/reschedule/seat-ref-456");
});
test("should include allRemainingBookings=true for recurring events in platform cancel URL", async () => {
const mockBookingInfoWithRecurring = {
...baseBookingInfo,
platformClientId: "platform-client-123",
platformCancelUrl: "https://platform.example.com/cancel",
platformRescheduleUrl: "https://platform.example.com/reschedule",
eventType: {
slug: "test-event",
recurringEvent: { freq: 2, count: 5, interval: 1 },
},
};
const result = await emailWorkflowService.generateEmailPayloadForEvtWorkflow({
evt: mockBookingInfoWithRecurring,
sendTo: ["attendee@example.com"],
hideBranding: false,
emailSubject: "Test Subject",
emailBody: "Cancel here: {CANCEL_URL}",
sender: "Cal.com",
action: WorkflowActions.EMAIL_ATTENDEE,
template: WorkflowTemplates.CUSTOM,
includeCalendarEvent: false,
triggerEvent: WorkflowTriggerEvents.BEFORE_EVENT,
});
expect(result.html).toContain("allRemainingBookings=true");
});
test("should include allRemainingBookings=false for non-recurring events in platform cancel URL", async () => {
const mockBookingInfoNonRecurring = {
...baseBookingInfo,
platformClientId: "platform-client-123",
platformCancelUrl: "https://platform.example.com/cancel",
platformRescheduleUrl: "https://platform.example.com/reschedule",
eventType: {
slug: "test-event",
recurringEvent: null,
},
};
const result = await emailWorkflowService.generateEmailPayloadForEvtWorkflow({
evt: mockBookingInfoNonRecurring,
sendTo: ["attendee@example.com"],
hideBranding: false,
emailSubject: "Test Subject",
emailBody: "Cancel here: {CANCEL_URL}",
sender: "Cal.com",
action: WorkflowActions.EMAIL_ATTENDEE,
template: WorkflowTemplates.CUSTOM,
includeCalendarEvent: false,
triggerEvent: WorkflowTriggerEvents.BEFORE_EVENT,
});
expect(result.html).toContain("allRemainingBookings=false");
});
test("should include allRemainingBookings=true for recurring events in standard cancel URL", async () => {
const mockBookingInfoWithRecurring = {
...baseBookingInfo,
platformClientId: null,
platformCancelUrl: null,
platformRescheduleUrl: null,
eventType: {
slug: "test-event",
recurringEvent: { freq: 2, count: 5, interval: 1 },
},
};
const result = await emailWorkflowService.generateEmailPayloadForEvtWorkflow({
evt: mockBookingInfoWithRecurring,
sendTo: ["attendee@example.com"],
hideBranding: false,
emailSubject: "Test Subject",
emailBody: "Cancel here: {CANCEL_URL}",
sender: "Cal.com",
action: WorkflowActions.EMAIL_ATTENDEE,
template: WorkflowTemplates.CUSTOM,
includeCalendarEvent: false,
triggerEvent: WorkflowTriggerEvents.BEFORE_EVENT,
});
// Standard cancel URL should include allRemainingBookings=true for recurring events
expect(result.html).toContain("https://cal.com/booking/booking-123");
expect(result.html).toContain("allRemainingBookings=true");
});
test("should include allRemainingBookings=false for non-recurring events in standard cancel URL", async () => {
const mockBookingInfoNonRecurring = {
...baseBookingInfo,
platformClientId: null,
platformCancelUrl: null,
platformRescheduleUrl: null,
eventType: {
slug: "test-event",
recurringEvent: null,
},
};
const result = await emailWorkflowService.generateEmailPayloadForEvtWorkflow({
evt: mockBookingInfoNonRecurring,
sendTo: ["attendee@example.com"],
hideBranding: false,
emailSubject: "Test Subject",
emailBody: "Cancel here: {CANCEL_URL}",
sender: "Cal.com",
action: WorkflowActions.EMAIL_ATTENDEE,
template: WorkflowTemplates.CUSTOM,
includeCalendarEvent: false,
triggerEvent: WorkflowTriggerEvents.BEFORE_EVENT,
});
// Standard cancel URL should include allRemainingBookings=false for non-recurring events
expect(result.html).toContain("https://cal.com/booking/booking-123");
expect(result.html).toContain("allRemainingBookings=false");
});
});
describe("generateEmailPayloadForEvtWorkflow - Auto Translation", () => {
const mockBookingInfo = {
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",
language: { locale: "en" },
timeFormat: "h:mma",
},
attendees: [
{
name: "Spanish Attendee",
email: "attendee@example.com",
timeZone: "Europe/Madrid",
language: { locale: "es" },
},
],
};
beforeEach(() => {
vi.mocked(mockTranslationService.getWorkflowStepTranslation).mockReset();
});
test("should use translated content when autoTranslateEnabled is true and translation exists", async () => {
vi.mocked(mockTranslationService.getWorkflowStepTranslation).mockResolvedValue({
translatedBody: "Cuerpo traducido",
translatedSubject: "Asunto traducido",
});
const result = await emailWorkflowService.generateEmailPayloadForEvtWorkflow({
evt: mockBookingInfo,
sendTo: ["attendee@example.com"],
hideBranding: false,
emailSubject: "Original Subject",
emailBody: "Original Body",
sender: "Cal.com",
action: WorkflowActions.EMAIL_ATTENDEE,
template: WorkflowTemplates.CUSTOM,
includeCalendarEvent: false,
triggerEvent: WorkflowTriggerEvents.BEFORE_EVENT,
workflowStepId: 123,
autoTranslateEnabled: true,
sourceLocale: "en",
});
expect(mockTranslationService.getWorkflowStepTranslation).toHaveBeenCalledTimes(1);
expect(result.subject).toBe("Asunto traducido");
expect(result.html).toContain("Cuerpo traducido");
});
test("should use original content when autoTranslateEnabled is false", async () => {
const result = await emailWorkflowService.generateEmailPayloadForEvtWorkflow({
evt: mockBookingInfo,
sendTo: ["attendee@example.com"],
hideBranding: false,
emailSubject: "Original Subject",
emailBody: "Original Body",
sender: "Cal.com",
action: WorkflowActions.EMAIL_ATTENDEE,
template: WorkflowTemplates.CUSTOM,
includeCalendarEvent: false,
triggerEvent: WorkflowTriggerEvents.BEFORE_EVENT,
workflowStepId: 123,
autoTranslateEnabled: false,
});
expect(mockTranslationService.getWorkflowStepTranslation).not.toHaveBeenCalled();
expect(result.subject).toBe("Original Subject");
});
test("should fallback to original content when translation not found", async () => {
vi.mocked(mockTranslationService.getWorkflowStepTranslation).mockResolvedValue({
translatedBody: null,
translatedSubject: null,
});
const result = await emailWorkflowService.generateEmailPayloadForEvtWorkflow({
evt: mockBookingInfo,
sendTo: ["attendee@example.com"],
hideBranding: false,
emailSubject: "Original Subject",
emailBody: "Original Body",
sender: "Cal.com",
action: WorkflowActions.EMAIL_ATTENDEE,
template: WorkflowTemplates.CUSTOM,
includeCalendarEvent: false,
triggerEvent: WorkflowTriggerEvents.BEFORE_EVENT,
workflowStepId: 123,
autoTranslateEnabled: true,
sourceLocale: "en",
});
expect(mockTranslationService.getWorkflowStepTranslation).toHaveBeenCalledTimes(1);
expect(result.subject).toBe("Original Subject");
});
test("should not translate for non-attendee actions", async () => {
const result = await emailWorkflowService.generateEmailPayloadForEvtWorkflow({
evt: mockBookingInfo,
sendTo: ["organizer@example.com"],
hideBranding: false,
emailSubject: "Original Subject",
emailBody: "Original Body",
sender: "Cal.com",
action: WorkflowActions.EMAIL_HOST,
template: WorkflowTemplates.CUSTOM,
includeCalendarEvent: false,
triggerEvent: WorkflowTriggerEvents.BEFORE_EVENT,
workflowStepId: 123,
autoTranslateEnabled: true,
sourceLocale: "en",
});
expect(mockTranslationService.getWorkflowStepTranslation).not.toHaveBeenCalled();
expect(result.subject).toBe("Original Subject");
});
});
describe("generateEmailPayloadForEvtWorkflow - Cal Video meeting URL", () => {
const mockBookingInfoWithCalVideo = {
uid: "booking-cal-video-123",
bookerUrl: "https://cal.com",
title: "Test Meeting with Cal Video",
startTime: "2024-12-01T10:00:00Z",
endTime: "2024-12-01T11:00:00Z",
organizer: {
name: "Organizer Name",
email: "organizer@example.com",
timeZone: "UTC",
language: { locale: "en" },
timeFormat: TimeFormat.TWELVE_HOUR,
},
attendees: [
{
name: "Attendee Name",
email: "attendee@example.com",
timeZone: "UTC",
language: { locale: "en" },
},
],
videoCallData: {
type: "daily_video",
url: "https://test-org.daily.co/test-room-name",
id: "test-room-name",
password: "test-password",
},
location: "Cal Video",
};
test("should use Cal.com video URL instead of daily.co URL for REMINDER template when using Cal video", async () => {
const result = await emailWorkflowService.generateEmailPayloadForEvtWorkflow({
evt: mockBookingInfoWithCalVideo,
sendTo: ["attendee@example.com"],
hideBranding: false,
emailSubject: "Test Subject",
emailBody: "", // Empty body with REMINDER template triggers REMINDER template
sender: "Cal.com",
action: WorkflowActions.EMAIL_ATTENDEE,
template: WorkflowTemplates.REMINDER,
includeCalendarEvent: false,
triggerEvent: WorkflowTriggerEvents.BEFORE_EVENT,
});
// The meetingUrl should NOT contain daily.co
expect(result.html).not.toContain("daily.co");
// The meetingUrl should contain the Cal.com video URL format
expect(result.html).toContain("/video/booking-cal-video-123");
});
test("should use Cal.com video URL instead of daily.co URL for custom template when using Cal video", async () => {
const customEmailBody = "Join the meeting at {MEETING_URL}";
const result = await emailWorkflowService.generateEmailPayloadForEvtWorkflow({
evt: mockBookingInfoWithCalVideo,
sendTo: ["attendee@example.com"],
hideBranding: false,
emailSubject: "Test Subject",
emailBody: customEmailBody,
sender: "Cal.com",
action: WorkflowActions.EMAIL_ATTENDEE,
template: WorkflowTemplates.CUSTOM,
includeCalendarEvent: false,
triggerEvent: WorkflowTriggerEvents.BEFORE_EVENT,
});
// The meetingUrl should NOT contain daily.co
expect(result.html).not.toContain("daily.co");
// The meetingUrl should contain the Cal.com video URL format
expect(result.html).toContain("/video/booking-cal-video-123");
});
test("should use Cal.com video URL format when videoCallData.url contains daily.co domain", async () => {
const mockBookingInfoWithDailyCoUrl = {
...mockBookingInfoWithCalVideo,
videoCallData: {
type: "daily_video",
url: "https://some-org.daily.co/another-room-name",
id: "another-room-name",
password: "test-password",
},
};
const result = await emailWorkflowService.generateEmailPayloadForEvtWorkflow({
evt: mockBookingInfoWithDailyCoUrl,
sendTo: ["attendee@example.com"],
hideBranding: false,
emailSubject: "Test Subject",
emailBody: "", // Empty body with REMINDER template triggers REMINDER template
sender: "Cal.com",
action: WorkflowActions.EMAIL_ATTENDEE,
template: WorkflowTemplates.REMINDER,
includeCalendarEvent: false,
triggerEvent: WorkflowTriggerEvents.BEFORE_EVENT,
});
// Even though videoCallData.url contains daily.co, the meetingUrl should NOT contain it
expect(result.html).not.toContain("daily.co");
expect(result.html).not.toContain("some-org.daily.co");
expect(result.html).not.toContain("another-room-name");
// Should use Cal.com video URL instead
expect(result.html).toContain("/video/booking-cal-video-123");
});
});
});