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>
This commit is contained in:
co-authored by
morgan@cal.com
Devin AI
parent
a694202c46
commit
592cb4fb7b
@@ -59,8 +59,12 @@ const mockWorkflowReminderRepository: Pick<WorkflowReminderRepository, "findById
|
||||
findByIdIncludeStepAndWorkflow: vi.fn(),
|
||||
};
|
||||
|
||||
const mockBookingSeatRepository: Pick<BookingSeatRepository, "getByUidIncludeAttendee"> = {
|
||||
const mockBookingSeatRepository: Pick<
|
||||
BookingSeatRepository,
|
||||
"getByUidIncludeAttendee" | "getByReferenceUidWithAttendeeDetails"
|
||||
> = {
|
||||
getByUidIncludeAttendee: vi.fn(),
|
||||
getByReferenceUidWithAttendeeDetails: vi.fn(),
|
||||
};
|
||||
|
||||
describe("EmailWorkflowService", () => {
|
||||
@@ -605,6 +609,345 @@ describe("EmailWorkflowService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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",
|
||||
|
||||
@@ -10,6 +10,12 @@ import { getSubmitterEmail } from "@calcom/features/tasker/tasks/triggerFormSubm
|
||||
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser";
|
||||
import { SENDER_NAME, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import {
|
||||
buildPlatformCancelLink,
|
||||
buildPlatformRescheduleLink,
|
||||
buildStandardCancelLink,
|
||||
buildStandardRescheduleLink,
|
||||
} from "@calcom/lib/LinkBuilder";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { getTranslation } from "@calcom/i18n/server";
|
||||
import { TimeFormat } from "@calcom/lib/timeFormat";
|
||||
@@ -485,6 +491,21 @@ export class EmailWorkflowService {
|
||||
location: evt.location,
|
||||
}) || bookingMetadataSchema.safeParse(evt.metadata || {}).data?.videoCallUrl;
|
||||
|
||||
const cancelLink = this.buildCancelLink({
|
||||
evt,
|
||||
bookerUrl,
|
||||
recipientEmail,
|
||||
isEmailAttendeeAction,
|
||||
seatReferenceUid,
|
||||
});
|
||||
|
||||
const rescheduleLink = this.buildRescheduleLink({
|
||||
evt,
|
||||
bookerUrl,
|
||||
recipientEmail,
|
||||
isEmailAttendeeAction,
|
||||
seatReferenceUid,
|
||||
});
|
||||
const variables: VariablesType = {
|
||||
eventName: evt.title || "",
|
||||
organizerName: evt.organizer.name,
|
||||
@@ -499,22 +520,9 @@ export class EmailWorkflowService {
|
||||
additionalNotes: evt.additionalNotes,
|
||||
responses: transformBookingResponsesToVariableFormat(evt.responses),
|
||||
meetingUrl,
|
||||
cancelLink: `${bookerUrl}/booking/${evt.uid}?cancel=true${
|
||||
recipientEmail ? `&cancelledBy=${encodeURIComponent(recipientEmail)}` : ""
|
||||
}${isEmailAttendeeAction && seatReferenceUid ? `&seatReferenceUid=${seatReferenceUid}` : ""}`,
|
||||
cancelLink,
|
||||
cancelReason: evt.cancellationReason,
|
||||
rescheduleLink: `${bookerUrl}/reschedule/${evt.uid}${
|
||||
recipientEmail
|
||||
? `?rescheduledBy=${encodeURIComponent(recipientEmail)}${
|
||||
isEmailAttendeeAction && seatReferenceUid
|
||||
? `&seatReferenceUid=${encodeURIComponent(seatReferenceUid)}`
|
||||
: ""
|
||||
}`
|
||||
: isEmailAttendeeAction && seatReferenceUid
|
||||
? `?seatReferenceUid=${encodeURIComponent(seatReferenceUid)}`
|
||||
: ""
|
||||
}`,
|
||||
|
||||
rescheduleLink,
|
||||
rescheduleReason: evt.rescheduleReason,
|
||||
ratingUrl: `${bookerUrl}/booking/${evt.uid}?rating`,
|
||||
noShowUrl: `${bookerUrl}/booking/${evt.uid}?noShow=true`,
|
||||
@@ -620,4 +628,74 @@ export class EmailWorkflowService {
|
||||
sender,
|
||||
};
|
||||
}
|
||||
|
||||
private buildCancelLink({
|
||||
evt,
|
||||
bookerUrl,
|
||||
recipientEmail,
|
||||
isEmailAttendeeAction,
|
||||
seatReferenceUid,
|
||||
}: {
|
||||
evt: BookingInfo;
|
||||
bookerUrl: string;
|
||||
recipientEmail: string | null;
|
||||
isEmailAttendeeAction: boolean;
|
||||
seatReferenceUid?: string;
|
||||
}): string {
|
||||
const seatUidToUse = isEmailAttendeeAction ? seatReferenceUid : undefined;
|
||||
|
||||
if (evt.platformClientId && evt.platformCancelUrl) {
|
||||
return buildPlatformCancelLink({
|
||||
platformCancelUrl: evt.platformCancelUrl,
|
||||
uid: evt.uid,
|
||||
slug: evt.eventType?.slug,
|
||||
username: evt.organizer.username ?? undefined,
|
||||
isRecurring: !!evt.eventType?.recurringEvent,
|
||||
seatReferenceUid: seatUidToUse,
|
||||
teamId: evt.team?.id,
|
||||
});
|
||||
}
|
||||
|
||||
return buildStandardCancelLink({
|
||||
bookerUrl,
|
||||
uid: evt.uid,
|
||||
cancelledBy: recipientEmail,
|
||||
seatReferenceUid: seatUidToUse,
|
||||
isRecurring: !!evt.eventType?.recurringEvent,
|
||||
});
|
||||
}
|
||||
|
||||
private buildRescheduleLink({
|
||||
evt,
|
||||
bookerUrl,
|
||||
recipientEmail,
|
||||
isEmailAttendeeAction,
|
||||
seatReferenceUid,
|
||||
}: {
|
||||
evt: BookingInfo;
|
||||
bookerUrl: string;
|
||||
recipientEmail: string | null;
|
||||
isEmailAttendeeAction: boolean;
|
||||
seatReferenceUid?: string;
|
||||
}): string {
|
||||
const seatUidToUse = isEmailAttendeeAction ? seatReferenceUid : undefined;
|
||||
|
||||
if (evt.platformClientId && evt.platformRescheduleUrl) {
|
||||
return buildPlatformRescheduleLink({
|
||||
platformRescheduleUrl: evt.platformRescheduleUrl,
|
||||
uid: evt.uid,
|
||||
slug: evt.eventType?.slug,
|
||||
username: evt.organizer.username ?? undefined,
|
||||
seatReferenceUid: seatUidToUse,
|
||||
teamId: evt.team?.id,
|
||||
});
|
||||
}
|
||||
|
||||
return buildStandardRescheduleLink({
|
||||
bookerUrl,
|
||||
uid: evt.uid,
|
||||
rescheduledBy: recipientEmail,
|
||||
seatReferenceUid: seatUidToUse,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,6 +290,10 @@ describe("WorkflowService.scheduleLazyEmailWorkflow", () => {
|
||||
{
|
||||
bookingUid: "booking-123",
|
||||
workflowReminderId: 1,
|
||||
platformClientId: "",
|
||||
platformRescheduleUrl: "",
|
||||
platformCancelUrl: "",
|
||||
platformBookingUrl: "",
|
||||
},
|
||||
{
|
||||
scheduledAt: expect.any(Date),
|
||||
@@ -334,6 +338,10 @@ describe("WorkflowService.scheduleLazyEmailWorkflow", () => {
|
||||
{
|
||||
bookingUid: "booking-456",
|
||||
workflowReminderId: 2,
|
||||
platformClientId: "",
|
||||
platformRescheduleUrl: "",
|
||||
platformCancelUrl: "",
|
||||
platformBookingUrl: "",
|
||||
},
|
||||
{
|
||||
scheduledAt: expect.any(Date),
|
||||
|
||||
@@ -13,10 +13,10 @@ import { tasker } from "@calcom/features/tasker";
|
||||
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { WorkflowTriggerEvents, WorkflowType, WorkflowMethods } from "@calcom/prisma/enums";
|
||||
import type { TimeUnit } from "@calcom/prisma/enums";
|
||||
import { WorkflowMethods, WorkflowTriggerEvents, WorkflowType } from "@calcom/prisma/enums";
|
||||
import type { FORM_SUBMITTED_WEBHOOK_RESPONSES } from "@calcom/routing-forms/lib/formSubmissionUtils";
|
||||
import { CalendarEvent } from "@calcom/types/Calendar";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
// TODO (Sean): Move most of the logic migrated in 16861 to this service
|
||||
export class WorkflowService {
|
||||
@@ -122,7 +122,7 @@ export class WorkflowService {
|
||||
let smsReminderNumber: string | null = null;
|
||||
if (form.fields) {
|
||||
const phoneField = form.fields.find((field) => field.type === "phone");
|
||||
if (phoneField && phoneField.identifier) {
|
||||
if (phoneField?.identifier) {
|
||||
const phoneResponse = responses[phoneField.identifier];
|
||||
if (phoneResponse?.response && typeof phoneResponse.response === "string") {
|
||||
smsReminderNumber = phoneResponse.response as string;
|
||||
@@ -207,7 +207,7 @@ export class WorkflowService {
|
||||
...workflows.filter(
|
||||
(workflow) =>
|
||||
workflow.trigger === WorkflowTriggerEvents.RESCHEDULE_EVENT ||
|
||||
this._beforeAfterEventTriggers.includes(workflow.trigger)
|
||||
WorkflowService._beforeAfterEventTriggers.includes(workflow.trigger)
|
||||
)
|
||||
);
|
||||
} else if (!isConfirmedByDefault) {
|
||||
@@ -218,7 +218,7 @@ export class WorkflowService {
|
||||
workflowsToTrigger.push(
|
||||
...workflows.filter(
|
||||
(workflow) =>
|
||||
this._beforeAfterEventTriggers.includes(workflow.trigger) ||
|
||||
WorkflowService._beforeAfterEventTriggers.includes(workflow.trigger) ||
|
||||
(isNormalBookingOrFirstRecurringSlot && workflow.trigger === WorkflowTriggerEvents.NEW_EVENT)
|
||||
)
|
||||
);
|
||||
@@ -255,7 +255,16 @@ export class WorkflowService {
|
||||
workflowTriggerEvent: "BEFORE_EVENT" | "AFTER_EVENT";
|
||||
workflowStepId: number;
|
||||
workflow: Pick<Workflow, "time" | "timeUnit">;
|
||||
evt: Pick<CalendarEvent, "uid" | "startTime" | "endTime">;
|
||||
evt: Pick<
|
||||
CalendarEvent,
|
||||
| "uid"
|
||||
| "startTime"
|
||||
| "endTime"
|
||||
| "platformClientId"
|
||||
| "platformRescheduleUrl"
|
||||
| "platformCancelUrl"
|
||||
| "platformBookingUrl"
|
||||
>;
|
||||
seatReferenceId?: string;
|
||||
}) {
|
||||
const { uid: bookingUid } = evt;
|
||||
@@ -315,6 +324,10 @@ export class WorkflowService {
|
||||
const taskerPayload = {
|
||||
bookingUid,
|
||||
workflowReminderId: workflowReminder.id,
|
||||
platformClientId: evt.platformClientId ?? "",
|
||||
platformRescheduleUrl: evt.platformRescheduleUrl ?? "",
|
||||
platformCancelUrl: evt.platformCancelUrl ?? "",
|
||||
platformBookingUrl: evt.platformBookingUrl ?? "",
|
||||
};
|
||||
|
||||
await tasker.create("sendWorkflowEmails", taskerPayload, {
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import type { Retell } from "retell-sdk";
|
||||
|
||||
import type { FORM_SUBMITTED_WEBHOOK_RESPONSES } from "@calcom/app-store/routing-forms/lib/formSubmissionUtils";
|
||||
import type { WorkflowPermissions } from "@calcom/features/workflows/repositories/WorkflowPermissionsRepository";
|
||||
import type { TimeFormat } from "@calcom/lib/timeFormat";
|
||||
import type {
|
||||
Prisma,
|
||||
Membership,
|
||||
Prisma,
|
||||
Workflow as PrismaWorkflow,
|
||||
WorkflowStep as PrismaWorkflowStep,
|
||||
} from "@calcom/prisma/client";
|
||||
import type { TimeUnit, WorkflowTemplates, WorkflowTriggerEvents } from "@calcom/prisma/enums";
|
||||
import { WorkflowActions } from "@calcom/prisma/enums";
|
||||
import type { CalEventResponses, RecurringEvent } from "@calcom/types/Calendar";
|
||||
import type {
|
||||
TimeUnit,
|
||||
WorkflowActions,
|
||||
WorkflowTemplates,
|
||||
WorkflowTriggerEvents,
|
||||
} from "@calcom/prisma/enums";
|
||||
import type { CalEventResponses, RecurringEvent, TeamMember } from "@calcom/types/Calendar";
|
||||
import type { MultiSelectCheckboxesOptionType as Option } from "@calcom/ui/components/form";
|
||||
import type { Retell } from "retell-sdk";
|
||||
import { z } from "zod";
|
||||
import { TIME_UNIT, WORKFLOW_ACTIONS, WORKFLOW_TEMPLATES, WORKFLOW_TRIGGER_EVENTS } from "./constants";
|
||||
|
||||
@@ -119,6 +122,13 @@ export type BookingInfo = {
|
||||
url?: string;
|
||||
};
|
||||
platformClientId?: string | null;
|
||||
platformRescheduleUrl?: string | null;
|
||||
platformCancelUrl?: string | null;
|
||||
team?: {
|
||||
name: string;
|
||||
members: TeamMember[];
|
||||
id: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type WorkflowContextData =
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { sendCustomWorkflowEmail } from "@calcom/emails/workflow-email-service";
|
||||
import { CalendarEventBuilder } from "@calcom/features/CalendarEventBuilder";
|
||||
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
|
||||
import { BookingSeatRepository } from "@calcom/features/bookings/repositories/BookingSeatRepository";
|
||||
import { CalendarEventBuilder } from "@calcom/features/CalendarEventBuilder";
|
||||
import { EmailWorkflowService } from "@calcom/features/ee/workflows/lib/service/EmailWorkflowService";
|
||||
import { WorkflowReminderRepository } from "@calcom/features/ee/workflows/repositories/WorkflowReminderRepository";
|
||||
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZSendWorkflowEmailsSchemaEager = z.object({
|
||||
to: z.array(z.string()),
|
||||
@@ -30,6 +29,10 @@ export const ZSendWorkflowEmailsSchemaEager = z.object({
|
||||
const ZSendWorkflowEmailsSchemaLazy = z.object({
|
||||
bookingUid: z.string(),
|
||||
workflowReminderId: z.number(),
|
||||
platformClientId: z.string().optional(),
|
||||
platformRescheduleUrl: z.string().optional(),
|
||||
platformCancelUrl: z.string().optional(),
|
||||
platformBookingUrl: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ZSendWorkflowEmailsSchema = z.union([
|
||||
@@ -53,7 +56,10 @@ export async function sendWorkflowEmails(payload: string): Promise<void> {
|
||||
|
||||
const calendarEvent = (
|
||||
await CalendarEventBuilder.fromBooking(booking, {
|
||||
platformClientId: bookingMetadata?.platformClientId,
|
||||
platformClientId: mailData.platformClientId ?? bookingMetadata?.platformClientId,
|
||||
platformRescheduleUrl: mailData.platformRescheduleUrl,
|
||||
platformCancelUrl: mailData.platformCancelUrl,
|
||||
platformBookingUrl: mailData.platformBookingUrl,
|
||||
})
|
||||
).build();
|
||||
|
||||
|
||||
@@ -15,6 +15,12 @@ import type {
|
||||
|
||||
import { WEBAPP_URL } from "./constants";
|
||||
import isSmsCalEmail from "./isSmsCalEmail";
|
||||
import {
|
||||
buildPlatformCancelLink,
|
||||
buildPlatformRescheduleLink,
|
||||
buildStandardCancelLink,
|
||||
buildStandardRescheduleLink,
|
||||
} from "./LinkBuilder";
|
||||
|
||||
const translator = short();
|
||||
|
||||
@@ -325,16 +331,15 @@ export const getPlatformCancelLink = (
|
||||
seatUid?: string
|
||||
): string => {
|
||||
if (calEvent.platformCancelUrl) {
|
||||
const platformCancelLink = new URL(`${calEvent.platformCancelUrl}/${bookingUid}`);
|
||||
platformCancelLink.searchParams.append("slug", calEvent.type);
|
||||
if (calEvent.organizer.username) {
|
||||
platformCancelLink.searchParams.append("username", calEvent.organizer.username);
|
||||
}
|
||||
platformCancelLink.searchParams.append("cancel", "true");
|
||||
platformCancelLink.searchParams.append("allRemainingBookings", String(!!calEvent.recurringEvent));
|
||||
if (seatUid) platformCancelLink.searchParams.append("seatReferenceUid", seatUid);
|
||||
if (calEvent?.team) platformCancelLink.searchParams.append("teamId", calEvent.team.id.toString());
|
||||
return platformCancelLink.toString();
|
||||
return buildPlatformCancelLink({
|
||||
platformCancelUrl: calEvent.platformCancelUrl,
|
||||
uid: bookingUid,
|
||||
slug: calEvent.type,
|
||||
username: calEvent.organizer.username ?? undefined,
|
||||
isRecurring: !!calEvent.recurringEvent,
|
||||
seatReferenceUid: seatUid,
|
||||
teamId: calEvent.team?.id,
|
||||
});
|
||||
}
|
||||
return "";
|
||||
};
|
||||
@@ -361,14 +366,13 @@ export const getCancelLink = (
|
||||
return getPlatformCancelLink(calEvent, Uid, seatReferenceUid);
|
||||
}
|
||||
|
||||
const cancelLink = new URL(`${calEvent.bookerUrl ?? WEBAPP_URL}/booking/${Uid}`);
|
||||
cancelLink.searchParams.append("cancel", "true");
|
||||
cancelLink.searchParams.append("allRemainingBookings", String(!!calEvent.recurringEvent));
|
||||
if (attendee?.email) {
|
||||
cancelLink.searchParams.append("cancelledBy", attendee.email);
|
||||
}
|
||||
if (seatReferenceUid) cancelLink.searchParams.append("seatReferenceUid", seatReferenceUid);
|
||||
return cancelLink.toString();
|
||||
return buildStandardCancelLink({
|
||||
bookerUrl: calEvent.bookerUrl ?? WEBAPP_URL,
|
||||
uid: Uid,
|
||||
cancelledBy: attendee?.email,
|
||||
seatReferenceUid: seatReferenceUid || undefined,
|
||||
isRecurring: !!calEvent.recurringEvent,
|
||||
});
|
||||
};
|
||||
|
||||
export const getPlatformRescheduleLink = (
|
||||
@@ -384,16 +388,14 @@ export const getPlatformRescheduleLink = (
|
||||
seatUid?: string
|
||||
): string => {
|
||||
if (calEvent.platformRescheduleUrl) {
|
||||
const platformRescheduleLink = new URL(
|
||||
`${calEvent.platformRescheduleUrl}/${seatUid ? seatUid : bookingUid}`
|
||||
);
|
||||
platformRescheduleLink.searchParams.append("slug", calEvent.type);
|
||||
if (calEvent.organizer.username) {
|
||||
platformRescheduleLink.searchParams.append("username", calEvent.organizer.username);
|
||||
}
|
||||
platformRescheduleLink.searchParams.append("reschedule", "true");
|
||||
if (calEvent?.team) platformRescheduleLink.searchParams.append("teamId", calEvent.team.id.toString());
|
||||
return platformRescheduleLink.toString();
|
||||
return buildPlatformRescheduleLink({
|
||||
platformRescheduleUrl: calEvent.platformRescheduleUrl,
|
||||
uid: bookingUid,
|
||||
slug: calEvent.type,
|
||||
username: calEvent.organizer.username ?? undefined,
|
||||
seatReferenceUid: seatUid,
|
||||
teamId: calEvent.team?.id,
|
||||
});
|
||||
}
|
||||
return "";
|
||||
};
|
||||
@@ -425,15 +427,13 @@ export const getRescheduleLink = ({
|
||||
return getPlatformRescheduleLink(calEvent, Uid, seatUid);
|
||||
}
|
||||
|
||||
const url = new URL(`${calEvent.bookerUrl ?? WEBAPP_URL}/reschedule/${seatUid ? seatUid : Uid}`);
|
||||
if (allowRescheduleForCancelledBooking) {
|
||||
url.searchParams.append("allowRescheduleForCancelledBooking", "true");
|
||||
}
|
||||
if (attendee?.email) {
|
||||
url.searchParams.append("rescheduledBy", attendee.email);
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
return buildStandardRescheduleLink({
|
||||
bookerUrl: calEvent.bookerUrl ?? WEBAPP_URL,
|
||||
uid: Uid,
|
||||
rescheduledBy: attendee?.email,
|
||||
seatReferenceUid: seatUid || undefined,
|
||||
allowRescheduleForCancelledBooking,
|
||||
});
|
||||
};
|
||||
|
||||
type RichDescriptionCalEvent = {
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
export interface PlatformLinkParams {
|
||||
platformClientId?: string | null;
|
||||
platformCancelUrl?: string | null;
|
||||
platformRescheduleUrl?: string | null;
|
||||
uid: string;
|
||||
bookerUrl: string;
|
||||
slug?: string;
|
||||
username?: string;
|
||||
teamId?: number;
|
||||
seatReferenceUid?: string;
|
||||
isRecurring?: boolean;
|
||||
}
|
||||
|
||||
export interface CancelLinkParams extends PlatformLinkParams {
|
||||
cancelledBy?: string | null;
|
||||
}
|
||||
|
||||
export interface RescheduleLinkParams extends PlatformLinkParams {
|
||||
rescheduledBy?: string | null;
|
||||
allowRescheduleForCancelledBooking?: boolean;
|
||||
}
|
||||
|
||||
export function buildPlatformCancelLink({
|
||||
platformCancelUrl,
|
||||
uid,
|
||||
slug,
|
||||
username,
|
||||
isRecurring,
|
||||
seatReferenceUid,
|
||||
teamId,
|
||||
}: {
|
||||
platformCancelUrl: string;
|
||||
uid: string | null | undefined;
|
||||
slug?: string | null;
|
||||
username?: string;
|
||||
isRecurring?: boolean;
|
||||
seatReferenceUid?: string;
|
||||
teamId?: number;
|
||||
}): string {
|
||||
const platformCancelLink = new URL(`${platformCancelUrl}/${uid}`);
|
||||
|
||||
if (slug) {
|
||||
platformCancelLink.searchParams.append("slug", slug);
|
||||
}
|
||||
if (username) {
|
||||
platformCancelLink.searchParams.append("username", username);
|
||||
}
|
||||
platformCancelLink.searchParams.append("cancel", "true");
|
||||
platformCancelLink.searchParams.append("allRemainingBookings", String(!!isRecurring));
|
||||
if (seatReferenceUid) {
|
||||
platformCancelLink.searchParams.append("seatReferenceUid", seatReferenceUid);
|
||||
}
|
||||
if (teamId) {
|
||||
platformCancelLink.searchParams.append("teamId", teamId.toString());
|
||||
}
|
||||
|
||||
return platformCancelLink.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a platform-specific reschedule link with all required query parameters.
|
||||
*/
|
||||
export function buildPlatformRescheduleLink({
|
||||
platformRescheduleUrl,
|
||||
uid,
|
||||
slug,
|
||||
username,
|
||||
seatReferenceUid,
|
||||
teamId,
|
||||
}: {
|
||||
platformRescheduleUrl: string;
|
||||
uid: string | null | undefined;
|
||||
slug?: string | null;
|
||||
username?: string;
|
||||
seatReferenceUid?: string;
|
||||
teamId?: number;
|
||||
}): string {
|
||||
const uidForReschedule = seatReferenceUid || uid;
|
||||
const platformRescheduleLink = new URL(`${platformRescheduleUrl}/${uidForReschedule}`);
|
||||
|
||||
if (slug) {
|
||||
platformRescheduleLink.searchParams.append("slug", slug);
|
||||
}
|
||||
if (username) {
|
||||
platformRescheduleLink.searchParams.append("username", username);
|
||||
}
|
||||
platformRescheduleLink.searchParams.append("reschedule", "true");
|
||||
if (teamId) {
|
||||
platformRescheduleLink.searchParams.append("teamId", teamId.toString());
|
||||
}
|
||||
|
||||
return platformRescheduleLink.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a standard (non-platform) cancel link.
|
||||
*/
|
||||
export function buildStandardCancelLink({
|
||||
bookerUrl,
|
||||
uid,
|
||||
cancelledBy,
|
||||
seatReferenceUid,
|
||||
isRecurring,
|
||||
}: {
|
||||
bookerUrl: string;
|
||||
uid: string | null | undefined;
|
||||
cancelledBy?: string | null;
|
||||
seatReferenceUid?: string;
|
||||
isRecurring?: boolean;
|
||||
}): string {
|
||||
const cancelLink = new URL(`${bookerUrl}/booking/${uid}`);
|
||||
cancelLink.searchParams.append("cancel", "true");
|
||||
cancelLink.searchParams.append("allRemainingBookings", String(!!isRecurring));
|
||||
if (cancelledBy) {
|
||||
cancelLink.searchParams.append("cancelledBy", cancelledBy);
|
||||
}
|
||||
if (seatReferenceUid) {
|
||||
cancelLink.searchParams.append("seatReferenceUid", seatReferenceUid);
|
||||
}
|
||||
return cancelLink.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a standard (non-platform) reschedule link.
|
||||
*/
|
||||
export function buildStandardRescheduleLink({
|
||||
bookerUrl,
|
||||
uid,
|
||||
rescheduledBy,
|
||||
seatReferenceUid,
|
||||
allowRescheduleForCancelledBooking,
|
||||
}: {
|
||||
bookerUrl: string;
|
||||
uid: string | null | undefined;
|
||||
rescheduledBy?: string | null;
|
||||
seatReferenceUid?: string;
|
||||
allowRescheduleForCancelledBooking?: boolean;
|
||||
}): string {
|
||||
const uidForReschedule = seatReferenceUid || uid;
|
||||
const url = new URL(`${bookerUrl}/reschedule/${uidForReschedule}`);
|
||||
|
||||
if (allowRescheduleForCancelledBooking) {
|
||||
url.searchParams.append("allowRescheduleForCancelledBooking", "true");
|
||||
}
|
||||
if (rescheduledBy) {
|
||||
url.searchParams.append("rescheduledBy", rescheduledBy);
|
||||
}
|
||||
if (seatReferenceUid) {
|
||||
url.searchParams.append("seatReferenceUid", seatReferenceUid);
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a cancel link, using platform URL if available, otherwise standard URL.
|
||||
*/
|
||||
export function buildCancelLink(params: CancelLinkParams): string {
|
||||
const {
|
||||
platformClientId,
|
||||
platformCancelUrl,
|
||||
uid,
|
||||
bookerUrl,
|
||||
slug,
|
||||
username,
|
||||
teamId,
|
||||
seatReferenceUid,
|
||||
isRecurring,
|
||||
cancelledBy,
|
||||
} = params;
|
||||
|
||||
if (platformClientId && platformCancelUrl) {
|
||||
return buildPlatformCancelLink({
|
||||
platformCancelUrl,
|
||||
uid,
|
||||
slug,
|
||||
username,
|
||||
isRecurring,
|
||||
seatReferenceUid,
|
||||
teamId,
|
||||
});
|
||||
}
|
||||
|
||||
return buildStandardCancelLink({
|
||||
bookerUrl,
|
||||
uid,
|
||||
cancelledBy,
|
||||
seatReferenceUid,
|
||||
isRecurring,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a reschedule link, using platform URL if available, otherwise standard URL.
|
||||
*/
|
||||
export function buildRescheduleLink(params: RescheduleLinkParams): string {
|
||||
const {
|
||||
platformClientId,
|
||||
platformRescheduleUrl,
|
||||
uid,
|
||||
bookerUrl,
|
||||
slug,
|
||||
username,
|
||||
teamId,
|
||||
seatReferenceUid,
|
||||
rescheduledBy,
|
||||
allowRescheduleForCancelledBooking,
|
||||
} = params;
|
||||
|
||||
if (platformClientId && platformRescheduleUrl) {
|
||||
return buildPlatformRescheduleLink({
|
||||
platformRescheduleUrl,
|
||||
uid,
|
||||
slug,
|
||||
username,
|
||||
seatReferenceUid,
|
||||
teamId,
|
||||
});
|
||||
}
|
||||
|
||||
return buildStandardRescheduleLink({
|
||||
bookerUrl,
|
||||
uid,
|
||||
rescheduledBy,
|
||||
seatReferenceUid,
|
||||
allowRescheduleForCancelledBooking,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user