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:
Morgan
2026-03-13 15:48:51 +04:00
committed by GitHub
co-authored by morgan@cal.com Devin AI
parent a694202c46
commit 592cb4fb7b
8 changed files with 756 additions and 70 deletions
@@ -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, {
+16 -6
View File
@@ -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();
+37 -37
View File
@@ -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 = {
+228
View File
@@ -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,
});
}