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("@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 = { 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 = { 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 = { 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); }); }); 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"); }); }); });