diff --git a/packages/features/ee/workflows/lib/service/EmailWorkflowService.test.ts b/packages/features/ee/workflows/lib/service/EmailWorkflowService.test.ts index d73c0a9755..1a3691807f 100644 --- a/packages/features/ee/workflows/lib/service/EmailWorkflowService.test.ts +++ b/packages/features/ee/workflows/lib/service/EmailWorkflowService.test.ts @@ -59,8 +59,12 @@ const mockWorkflowReminderRepository: Pick = { +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", diff --git a/packages/features/ee/workflows/lib/service/EmailWorkflowService.ts b/packages/features/ee/workflows/lib/service/EmailWorkflowService.ts index d4f209ec41..dae2f68e74 100644 --- a/packages/features/ee/workflows/lib/service/EmailWorkflowService.ts +++ b/packages/features/ee/workflows/lib/service/EmailWorkflowService.ts @@ -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, + }); + } } diff --git a/packages/features/ee/workflows/lib/service/WorkflowService.test.ts b/packages/features/ee/workflows/lib/service/WorkflowService.test.ts index 67b9053640..d441d1a986 100644 --- a/packages/features/ee/workflows/lib/service/WorkflowService.test.ts +++ b/packages/features/ee/workflows/lib/service/WorkflowService.test.ts @@ -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), diff --git a/packages/features/ee/workflows/lib/service/WorkflowService.ts b/packages/features/ee/workflows/lib/service/WorkflowService.ts index a3b7608df1..169184b689 100644 --- a/packages/features/ee/workflows/lib/service/WorkflowService.ts +++ b/packages/features/ee/workflows/lib/service/WorkflowService.ts @@ -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; - evt: Pick; + 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, { diff --git a/packages/features/ee/workflows/lib/types.ts b/packages/features/ee/workflows/lib/types.ts index 9b75c187c6..8520ecd11a 100644 --- a/packages/features/ee/workflows/lib/types.ts +++ b/packages/features/ee/workflows/lib/types.ts @@ -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 = diff --git a/packages/features/tasker/tasks/sendWorkflowEmails.ts b/packages/features/tasker/tasks/sendWorkflowEmails.ts index 1a0a516e60..86078e65d0 100644 --- a/packages/features/tasker/tasks/sendWorkflowEmails.ts +++ b/packages/features/tasker/tasks/sendWorkflowEmails.ts @@ -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 { const calendarEvent = ( await CalendarEventBuilder.fromBooking(booking, { - platformClientId: bookingMetadata?.platformClientId, + platformClientId: mailData.platformClientId ?? bookingMetadata?.platformClientId, + platformRescheduleUrl: mailData.platformRescheduleUrl, + platformCancelUrl: mailData.platformCancelUrl, + platformBookingUrl: mailData.platformBookingUrl, }) ).build(); diff --git a/packages/lib/CalEventParser.ts b/packages/lib/CalEventParser.ts index 5e5766ba70..ae05ca01c3 100644 --- a/packages/lib/CalEventParser.ts +++ b/packages/lib/CalEventParser.ts @@ -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 = { diff --git a/packages/lib/LinkBuilder.ts b/packages/lib/LinkBuilder.ts new file mode 100644 index 0000000000..b2b14dd109 --- /dev/null +++ b/packages/lib/LinkBuilder.ts @@ -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, + }); +}