Skip to content

Commit 592cb4f

Browse files
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 1d4d362. * 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>
1 parent a694202 commit 592cb4f

File tree

8 files changed

+756
-70
lines changed

8 files changed

+756
-70
lines changed

packages/features/ee/workflows/lib/service/EmailWorkflowService.test.ts

Lines changed: 344 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,12 @@ const mockWorkflowReminderRepository: Pick<WorkflowReminderRepository, "findById
5959
findByIdIncludeStepAndWorkflow: vi.fn(),
6060
};
6161

62-
const mockBookingSeatRepository: Pick<BookingSeatRepository, "getByUidIncludeAttendee"> = {
62+
const mockBookingSeatRepository: Pick<
63+
BookingSeatRepository,
64+
"getByUidIncludeAttendee" | "getByReferenceUidWithAttendeeDetails"
65+
> = {
6366
getByUidIncludeAttendee: vi.fn(),
67+
getByReferenceUidWithAttendeeDetails: vi.fn(),
6468
};
6569

6670
describe("EmailWorkflowService", () => {
@@ -605,6 +609,345 @@ describe("EmailWorkflowService", () => {
605609
});
606610
});
607611

612+
describe("generateEmailPayloadForEvtWorkflow - Platform URL handling", () => {
613+
const baseBookingInfo = {
614+
uid: "booking-123",
615+
bookerUrl: "https://cal.com",
616+
title: "Test Meeting",
617+
startTime: "2024-12-01T10:00:00Z",
618+
endTime: "2024-12-01T11:00:00Z",
619+
organizer: {
620+
name: "Organizer Name",
621+
email: "organizer@example.com",
622+
timeZone: "UTC",
623+
language: { locale: "en" },
624+
timeFormat: "h:mma",
625+
username: "organizer-user",
626+
},
627+
attendees: [
628+
{
629+
name: "Attendee Name",
630+
email: "attendee@example.com",
631+
timeZone: "UTC",
632+
language: { locale: "en" },
633+
},
634+
],
635+
eventType: {
636+
slug: "test-event",
637+
recurringEvent: null,
638+
},
639+
};
640+
641+
test("should use platform cancel URL when platformClientId and platformCancelUrl are provided", async () => {
642+
const mockBookingInfoWithPlatform = {
643+
...baseBookingInfo,
644+
platformClientId: "platform-client-123",
645+
platformCancelUrl: "https://platform.example.com/cancel",
646+
platformRescheduleUrl: "https://platform.example.com/reschedule",
647+
};
648+
649+
const result = await emailWorkflowService.generateEmailPayloadForEvtWorkflow({
650+
evt: mockBookingInfoWithPlatform,
651+
sendTo: ["attendee@example.com"],
652+
hideBranding: false,
653+
emailSubject: "Test Subject",
654+
emailBody: "Cancel here: {CANCEL_URL}",
655+
sender: "Cal.com",
656+
action: WorkflowActions.EMAIL_ATTENDEE,
657+
template: WorkflowTemplates.CUSTOM,
658+
includeCalendarEvent: false,
659+
triggerEvent: WorkflowTriggerEvents.BEFORE_EVENT,
660+
});
661+
662+
// The cancel link should use the platform URL
663+
expect(result.html).toContain("https://platform.example.com/cancel/booking-123");
664+
expect(result.html).toContain("slug=test-event");
665+
expect(result.html).toContain("username=organizer-user");
666+
expect(result.html).toContain("cancel=true");
667+
});
668+
669+
test("should use platform reschedule URL when platformClientId and platformRescheduleUrl are provided", async () => {
670+
const mockBookingInfoWithPlatform = {
671+
...baseBookingInfo,
672+
platformClientId: "platform-client-123",
673+
platformCancelUrl: "https://platform.example.com/cancel",
674+
platformRescheduleUrl: "https://platform.example.com/reschedule",
675+
};
676+
677+
const result = await emailWorkflowService.generateEmailPayloadForEvtWorkflow({
678+
evt: mockBookingInfoWithPlatform,
679+
sendTo: ["attendee@example.com"],
680+
hideBranding: false,
681+
emailSubject: "Test Subject",
682+
emailBody: "Reschedule here: {RESCHEDULE_URL}",
683+
sender: "Cal.com",
684+
action: WorkflowActions.EMAIL_ATTENDEE,
685+
template: WorkflowTemplates.CUSTOM,
686+
includeCalendarEvent: false,
687+
triggerEvent: WorkflowTriggerEvents.BEFORE_EVENT,
688+
});
689+
690+
// The reschedule link should use the platform URL
691+
expect(result.html).toContain("https://platform.example.com/reschedule/booking-123");
692+
expect(result.html).toContain("slug=test-event");
693+
expect(result.html).toContain("username=organizer-user");
694+
expect(result.html).toContain("reschedule=true");
695+
});
696+
697+
test("should use standard bookerUrl when platformClientId is not provided", async () => {
698+
const mockBookingInfoWithoutPlatform = {
699+
...baseBookingInfo,
700+
platformClientId: null,
701+
platformCancelUrl: null,
702+
platformRescheduleUrl: null,
703+
};
704+
705+
const result = await emailWorkflowService.generateEmailPayloadForEvtWorkflow({
706+
evt: mockBookingInfoWithoutPlatform,
707+
sendTo: ["attendee@example.com"],
708+
hideBranding: false,
709+
emailSubject: "Test Subject",
710+
emailBody: "Cancel: {CANCEL_URL} Reschedule: {RESCHEDULE_URL}",
711+
sender: "Cal.com",
712+
action: WorkflowActions.EMAIL_ATTENDEE,
713+
template: WorkflowTemplates.CUSTOM,
714+
includeCalendarEvent: false,
715+
triggerEvent: WorkflowTriggerEvents.BEFORE_EVENT,
716+
});
717+
718+
// Should use standard bookerUrl pattern
719+
expect(result.html).toContain("https://cal.com/booking/booking-123?cancel=true");
720+
expect(result.html).toContain("https://cal.com/reschedule/booking-123");
721+
// Should NOT contain platform URLs
722+
expect(result.html).not.toContain("platform.example.com");
723+
});
724+
725+
test("should include teamId in platform cancel URL when team is provided", async () => {
726+
const mockBookingInfoWithTeam = {
727+
...baseBookingInfo,
728+
platformClientId: "platform-client-123",
729+
platformCancelUrl: "https://platform.example.com/cancel",
730+
platformRescheduleUrl: "https://platform.example.com/reschedule",
731+
team: {
732+
id: 42,
733+
name: "Test Team",
734+
members: [],
735+
},
736+
};
737+
738+
const result = await emailWorkflowService.generateEmailPayloadForEvtWorkflow({
739+
evt: mockBookingInfoWithTeam,
740+
sendTo: ["attendee@example.com"],
741+
hideBranding: false,
742+
emailSubject: "Test Subject",
743+
emailBody: "Cancel here: {CANCEL_URL}",
744+
sender: "Cal.com",
745+
action: WorkflowActions.EMAIL_ATTENDEE,
746+
template: WorkflowTemplates.CUSTOM,
747+
includeCalendarEvent: false,
748+
triggerEvent: WorkflowTriggerEvents.BEFORE_EVENT,
749+
});
750+
751+
expect(result.html).toContain("teamId=42");
752+
});
753+
754+
test("should include teamId in platform reschedule URL when team is provided", async () => {
755+
const mockBookingInfoWithTeam = {
756+
...baseBookingInfo,
757+
platformClientId: "platform-client-123",
758+
platformCancelUrl: "https://platform.example.com/cancel",
759+
platformRescheduleUrl: "https://platform.example.com/reschedule",
760+
team: {
761+
id: 42,
762+
name: "Test Team",
763+
members: [],
764+
},
765+
};
766+
767+
const result = await emailWorkflowService.generateEmailPayloadForEvtWorkflow({
768+
evt: mockBookingInfoWithTeam,
769+
sendTo: ["attendee@example.com"],
770+
hideBranding: false,
771+
emailSubject: "Test Subject",
772+
emailBody: "Reschedule here: {RESCHEDULE_URL}",
773+
sender: "Cal.com",
774+
action: WorkflowActions.EMAIL_ATTENDEE,
775+
template: WorkflowTemplates.CUSTOM,
776+
includeCalendarEvent: false,
777+
triggerEvent: WorkflowTriggerEvents.BEFORE_EVENT,
778+
});
779+
780+
expect(result.html).toContain("teamId=42");
781+
});
782+
783+
test("should include seatReferenceUid in platform cancel URL for seated events", async () => {
784+
const mockBookingInfoWithPlatform = {
785+
...baseBookingInfo,
786+
platformClientId: "platform-client-123",
787+
platformCancelUrl: "https://platform.example.com/cancel",
788+
platformRescheduleUrl: "https://platform.example.com/reschedule",
789+
};
790+
791+
const result = await emailWorkflowService.generateEmailPayloadForEvtWorkflow({
792+
evt: mockBookingInfoWithPlatform,
793+
sendTo: ["attendee@example.com"],
794+
seatReferenceUid: "seat-ref-456",
795+
hideBranding: false,
796+
emailSubject: "Test Subject",
797+
emailBody: "Cancel here: {CANCEL_URL}",
798+
sender: "Cal.com",
799+
action: WorkflowActions.EMAIL_ATTENDEE,
800+
template: WorkflowTemplates.CUSTOM,
801+
includeCalendarEvent: false,
802+
triggerEvent: WorkflowTriggerEvents.BEFORE_EVENT,
803+
});
804+
805+
expect(result.html).toContain("seatReferenceUid=seat-ref-456");
806+
});
807+
808+
test("should use seatReferenceUid as uid in platform reschedule URL for seated events", async () => {
809+
const mockBookingInfoWithPlatform = {
810+
...baseBookingInfo,
811+
platformClientId: "platform-client-123",
812+
platformCancelUrl: "https://platform.example.com/cancel",
813+
platformRescheduleUrl: "https://platform.example.com/reschedule",
814+
};
815+
816+
const result = await emailWorkflowService.generateEmailPayloadForEvtWorkflow({
817+
evt: mockBookingInfoWithPlatform,
818+
sendTo: ["attendee@example.com"],
819+
seatReferenceUid: "seat-ref-456",
820+
hideBranding: false,
821+
emailSubject: "Test Subject",
822+
emailBody: "Reschedule here: {RESCHEDULE_URL}",
823+
sender: "Cal.com",
824+
action: WorkflowActions.EMAIL_ATTENDEE,
825+
template: WorkflowTemplates.CUSTOM,
826+
includeCalendarEvent: false,
827+
triggerEvent: WorkflowTriggerEvents.BEFORE_EVENT,
828+
});
829+
830+
// For seated events with EMAIL_ATTENDEE action, the reschedule URL should use seatReferenceUid
831+
expect(result.html).toContain("https://platform.example.com/reschedule/seat-ref-456");
832+
});
833+
834+
test("should include allRemainingBookings=true for recurring events in platform cancel URL", async () => {
835+
const mockBookingInfoWithRecurring = {
836+
...baseBookingInfo,
837+
platformClientId: "platform-client-123",
838+
platformCancelUrl: "https://platform.example.com/cancel",
839+
platformRescheduleUrl: "https://platform.example.com/reschedule",
840+
eventType: {
841+
slug: "test-event",
842+
recurringEvent: { freq: 2, count: 5, interval: 1 },
843+
},
844+
};
845+
846+
const result = await emailWorkflowService.generateEmailPayloadForEvtWorkflow({
847+
evt: mockBookingInfoWithRecurring,
848+
sendTo: ["attendee@example.com"],
849+
hideBranding: false,
850+
emailSubject: "Test Subject",
851+
emailBody: "Cancel here: {CANCEL_URL}",
852+
sender: "Cal.com",
853+
action: WorkflowActions.EMAIL_ATTENDEE,
854+
template: WorkflowTemplates.CUSTOM,
855+
includeCalendarEvent: false,
856+
triggerEvent: WorkflowTriggerEvents.BEFORE_EVENT,
857+
});
858+
859+
expect(result.html).toContain("allRemainingBookings=true");
860+
});
861+
862+
test("should include allRemainingBookings=false for non-recurring events in platform cancel URL", async () => {
863+
const mockBookingInfoNonRecurring = {
864+
...baseBookingInfo,
865+
platformClientId: "platform-client-123",
866+
platformCancelUrl: "https://platform.example.com/cancel",
867+
platformRescheduleUrl: "https://platform.example.com/reschedule",
868+
eventType: {
869+
slug: "test-event",
870+
recurringEvent: null,
871+
},
872+
};
873+
874+
const result = await emailWorkflowService.generateEmailPayloadForEvtWorkflow({
875+
evt: mockBookingInfoNonRecurring,
876+
sendTo: ["attendee@example.com"],
877+
hideBranding: false,
878+
emailSubject: "Test Subject",
879+
emailBody: "Cancel here: {CANCEL_URL}",
880+
sender: "Cal.com",
881+
action: WorkflowActions.EMAIL_ATTENDEE,
882+
template: WorkflowTemplates.CUSTOM,
883+
includeCalendarEvent: false,
884+
triggerEvent: WorkflowTriggerEvents.BEFORE_EVENT,
885+
});
886+
887+
expect(result.html).toContain("allRemainingBookings=false");
888+
});
889+
890+
test("should include allRemainingBookings=true for recurring events in standard cancel URL", async () => {
891+
const mockBookingInfoWithRecurring = {
892+
...baseBookingInfo,
893+
platformClientId: null,
894+
platformCancelUrl: null,
895+
platformRescheduleUrl: null,
896+
eventType: {
897+
slug: "test-event",
898+
recurringEvent: { freq: 2, count: 5, interval: 1 },
899+
},
900+
};
901+
902+
const result = await emailWorkflowService.generateEmailPayloadForEvtWorkflow({
903+
evt: mockBookingInfoWithRecurring,
904+
sendTo: ["attendee@example.com"],
905+
hideBranding: false,
906+
emailSubject: "Test Subject",
907+
emailBody: "Cancel here: {CANCEL_URL}",
908+
sender: "Cal.com",
909+
action: WorkflowActions.EMAIL_ATTENDEE,
910+
template: WorkflowTemplates.CUSTOM,
911+
includeCalendarEvent: false,
912+
triggerEvent: WorkflowTriggerEvents.BEFORE_EVENT,
913+
});
914+
915+
// Standard cancel URL should include allRemainingBookings=true for recurring events
916+
expect(result.html).toContain("https://cal.com/booking/booking-123");
917+
expect(result.html).toContain("allRemainingBookings=true");
918+
});
919+
920+
test("should include allRemainingBookings=false for non-recurring events in standard cancel URL", async () => {
921+
const mockBookingInfoNonRecurring = {
922+
...baseBookingInfo,
923+
platformClientId: null,
924+
platformCancelUrl: null,
925+
platformRescheduleUrl: null,
926+
eventType: {
927+
slug: "test-event",
928+
recurringEvent: null,
929+
},
930+
};
931+
932+
const result = await emailWorkflowService.generateEmailPayloadForEvtWorkflow({
933+
evt: mockBookingInfoNonRecurring,
934+
sendTo: ["attendee@example.com"],
935+
hideBranding: false,
936+
emailSubject: "Test Subject",
937+
emailBody: "Cancel here: {CANCEL_URL}",
938+
sender: "Cal.com",
939+
action: WorkflowActions.EMAIL_ATTENDEE,
940+
template: WorkflowTemplates.CUSTOM,
941+
includeCalendarEvent: false,
942+
triggerEvent: WorkflowTriggerEvents.BEFORE_EVENT,
943+
});
944+
945+
// Standard cancel URL should include allRemainingBookings=false for non-recurring events
946+
expect(result.html).toContain("https://cal.com/booking/booking-123");
947+
expect(result.html).toContain("allRemainingBookings=false");
948+
});
949+
});
950+
608951
describe("generateEmailPayloadForEvtWorkflow - Auto Translation", () => {
609952
const mockBookingInfo = {
610953
uid: "booking-123",

0 commit comments

Comments
 (0)