From 3bbba9c6cb693f8f26a379028e3c540cea0fbf1c Mon Sep 17 00:00:00 2001 From: Amit Sharma <74371312+Amit91848@users.noreply.github.com> Date: Thu, 4 Sep 2025 16:55:09 +0530 Subject: [PATCH] feat: add 5 new workflow triggers for booking events (#23068) * feat: add 5 new workflow triggers for booking events - Add BOOKING_REJECTED, BOOKING_REQUESTED, BOOKING_PAYMENT_INITIATED, BOOKING_PAID, BOOKING_NO_SHOW_UPDATED to WorkflowTriggerEvents enum - Update workflow constants to include new trigger options - Implement workflow trigger logic for booking rejected and requested events - Add translations for new workflow triggers following {enum}_trigger format - Generate updated Prisma types for new schema changes Co-Authored-By: amit@cal.com * fix: type check, remove as any * feat: add workflow trigger for BOOKING_REQUESTED in handleNewBooking.ts - Add WorkflowTriggerEvents import to handleNewBooking.ts - Implement workflow trigger logic for BOOKING_REQUESTED in else block - Filter workflows by BOOKING_REQUESTED trigger and call scheduleWorkflowReminders - Use proper calendar event object construction without type casting - Add error handling for workflow reminder scheduling Co-Authored-By: amit@cal.com * fix: resolve type errors in workflow trigger implementations - Add proper database includes for user information in handleConfirmation.ts - Fix ExtendedCalendarEvent type structure with correct hosts mapping - Add missing properties to calendar event objects in handleMarkNoShow.ts - Ensure all workflow triggers follow proper type patterns Co-Authored-By: amit@cal.com * feat: add workflow test configurations for new booking triggers - Add workflow configurations for BOOKING_REQUESTED and BOOKING_PAYMENT_INITIATED in fresh-booking.test.ts - Add workflow configuration for BOOKING_REJECTED in confirm.handler.test.ts - Enable previously skipped confirm.handler.test.ts - Remove workflow test assertions temporarily until triggers are fully functional - Maintain webhook test coverage while adding workflow test infrastructure Co-Authored-By: amit@cal.com * fix: add missing mockSuccessfulVideoMeetingCreation import to confirm.handler.test.ts - Import mockSuccessfulVideoMeetingCreation from bookingScenario utils - Add mock call to BOOKING_REJECTED workflow test case - Resolves ReferenceError that was causing unit test CI failure Co-Authored-By: amit@cal.com * refactor: improve _scheduleWorkflowReminders readability and add missing booking trigger events - Extract complex conditional logic into helper functions (isImmediateTrigger, isTimeBased, shouldProcessWorkflow) - Add missing workflow trigger events with immediate execution logic - Update test workflows to use different actions (EMAIL_ATTENDEE, SMS_ATTENDEE) for better differentiation - Fix translation function mock in confirm.handler.test.ts using mockNoTranslations utility - Maintain existing functionality while improving code maintainability Co-Authored-By: amit@cal.com * filter outside scheduleWorkflowReminder * fix type check * chore: add more tests * test: add comprehensive unit tests for handleMarkNoShow with webhook and workflow coverage - Create handleMarkNoShow.test.ts following confirm.handler.test.ts pattern - Add expectBookingNoShowUpdatedWebhookToHaveBeenFired utility function - Test both webhook and workflow triggers for BOOKING_NO_SHOW_UPDATED - Cover attendee/host no-show scenarios, multiple attendees, and error cases - All 6 unit tests pass with proper mocking of external dependencies Co-Authored-By: amit@cal.com * Revert "test: add comprehensive unit tests for handleMarkNoShow with webhook and workflow coverage" This reverts commit 764299220279f0c012392dec24d3150246bfc4ad. * fix: add new workflow triggers to api/v2 * update swagger docs * fix: e2e * fix type check * fix tests, add test for before after events * fix unit tests * revert confirm.handler.test * fix: unit tests * review fixes * refactor WorkflowService * remove logs * remove unused * fix: type check * fix: missed before after events for recurring * fix: calendarEvent handleMarkNoShow * fix error message Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * review fixes * add missing BOOKING_PAID workflow trigger * fix pathname * fix: test for BOOKING_REQUESTED * review fixes * Update packages/features/bookings/lib/handleSeats/handleSeats.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> --- .../workflows/inputs/create-workflow.input.ts | 35 ++++ .../workflows/inputs/update-workflow.input.ts | 30 +++ .../inputs/workflow-trigger.input.ts | 55 +++++ apps/api/v2/swagger/documentation.json | 102 ++++++++- apps/web/playwright/fixtures/workflows.ts | 22 +- apps/web/playwright/workflow.e2e.ts | 20 +- apps/web/public/static/locales/en/common.json | 5 + .../bookings/lib/handleBookingRequested.ts | 39 +++- .../test/handleCancelBooking.test.ts | 24 ++- .../bookings/lib/handleConfirmation.ts | 39 +++- .../features/bookings/lib/handleNewBooking.ts | 53 ++++- .../test/fresh-booking.test.ts | 83 +++++++- .../bookings/lib/handleSeats/handleSeats.ts | 12 +- .../features/ee/workflows/lib/constants.ts | 16 ++ .../lib/reminders/emailReminderManager.ts | 7 +- .../lib/reminders/reminderScheduler.ts | 26 +-- .../lib/reminders/smsReminderManager.ts | 7 +- .../ee/workflows/lib/reminders/utils.ts | 7 +- .../lib/reminders/whatsappReminderManager.ts | 7 +- packages/features/handleMarkNoShow.ts | 197 +++++++++++++++++- packages/lib/payment/getBooking.ts | 19 ++ packages/lib/server/service/workflows.ts | 65 ++++++ .../migration.sql | 13 ++ packages/prisma/schema.prisma | 5 + .../viewer/bookings/confirm.handler.test.ts | 105 +++++++++- .../viewer/bookings/confirm.handler.ts | 27 +++ .../trpc/server/routers/viewer/payments.tsx | 72 ++++++- 27 files changed, 1007 insertions(+), 85 deletions(-) create mode 100644 packages/prisma/migrations/20250813182504_adding_booking_triggers_to_workflows/migration.sql diff --git a/apps/api/v2/src/modules/workflows/inputs/create-workflow.input.ts b/apps/api/v2/src/modules/workflows/inputs/create-workflow.input.ts index a1c362d1a3..9c0fcd22db 100644 --- a/apps/api/v2/src/modules/workflows/inputs/create-workflow.input.ts +++ b/apps/api/v2/src/modules/workflows/inputs/create-workflow.input.ts @@ -33,6 +33,11 @@ import { AFTER_HOSTS_CAL_VIDEO_NO_SHOW, BaseWorkflowTriggerDto, BEFORE_EVENT, + BOOKING_NO_SHOW_UPDATED, + BOOKING_PAID, + BOOKING_PAYMENT_INITIATED, + BOOKING_REJECTED, + BOOKING_REQUESTED, EVENT_CANCELLED, NEW_EVENT, OnAfterCalVideoGuestsNoShowTriggerDto, @@ -41,6 +46,11 @@ import { OnBeforeEventTriggerDto, OnCancelTriggerDto, OnCreationTriggerDto, + OnNoShowUpdateTriggerDto, + OnPaidTriggerDto, + OnPaymentInitiatedTriggerDto, + OnRejectedTriggerDto, + OnRequestedTriggerDto, OnRescheduleTriggerDto, RESCHEDULE_EVENT, } from "./workflow-trigger.input"; @@ -73,6 +83,11 @@ export type TriggerDtoType = | OnRescheduleTriggerDto | OnCancelTriggerDto | OnAfterCalVideoGuestsNoShowTriggerDto + | OnRejectedTriggerDto + | OnRequestedTriggerDto + | OnPaymentInitiatedTriggerDto + | OnPaidTriggerDto + | OnNoShowUpdateTriggerDto | OnAfterCalVideoHostsNoShowTriggerDto; @ApiExtraModels( @@ -81,6 +96,11 @@ export type TriggerDtoType = OnCancelTriggerDto, OnCreationTriggerDto, OnRescheduleTriggerDto, + OnNoShowUpdateTriggerDto, + OnRejectedTriggerDto, + OnRequestedTriggerDto, + OnPaymentInitiatedTriggerDto, + OnPaidTriggerDto, OnAfterCalVideoGuestsNoShowTriggerDto, OnAfterCalVideoHostsNoShowTriggerDto, WorkflowEmailAddressStepDto, @@ -112,6 +132,11 @@ export class CreateWorkflowDto { { $ref: getSchemaPath(OnRescheduleTriggerDto) }, { $ref: getSchemaPath(OnAfterCalVideoGuestsNoShowTriggerDto) }, { $ref: getSchemaPath(OnAfterCalVideoHostsNoShowTriggerDto) }, + { $ref: getSchemaPath(OnRejectedTriggerDto) }, + { $ref: getSchemaPath(OnRequestedTriggerDto) }, + { $ref: getSchemaPath(OnPaidTriggerDto) }, + { $ref: getSchemaPath(OnPaymentInitiatedTriggerDto) }, + { $ref: getSchemaPath(OnNoShowUpdateTriggerDto) }, ], }) @ValidateNested() @@ -126,6 +151,11 @@ export class CreateWorkflowDto { { value: OnRescheduleTriggerDto, name: RESCHEDULE_EVENT }, { value: OnAfterCalVideoGuestsNoShowTriggerDto, name: AFTER_GUESTS_CAL_VIDEO_NO_SHOW }, { value: OnAfterCalVideoHostsNoShowTriggerDto, name: AFTER_HOSTS_CAL_VIDEO_NO_SHOW }, + { value: OnRequestedTriggerDto, name: BOOKING_REQUESTED }, + { value: OnRejectedTriggerDto, name: BOOKING_REJECTED }, + { value: OnPaymentInitiatedTriggerDto, name: BOOKING_PAYMENT_INITIATED }, + { value: OnPaidTriggerDto, name: BOOKING_PAID }, + { value: OnNoShowUpdateTriggerDto, name: BOOKING_NO_SHOW_UPDATED }, ], }, }) @@ -135,6 +165,11 @@ export class CreateWorkflowDto { | OnCreationTriggerDto | OnRescheduleTriggerDto | OnCancelTriggerDto + | OnRejectedTriggerDto + | OnRequestedTriggerDto + | OnPaidTriggerDto + | OnPaymentInitiatedTriggerDto + | OnNoShowUpdateTriggerDto | OnAfterCalVideoGuestsNoShowTriggerDto | OnAfterCalVideoHostsNoShowTriggerDto; diff --git a/apps/api/v2/src/modules/workflows/inputs/update-workflow.input.ts b/apps/api/v2/src/modules/workflows/inputs/update-workflow.input.ts index 7d70e7e742..124861bae8 100644 --- a/apps/api/v2/src/modules/workflows/inputs/update-workflow.input.ts +++ b/apps/api/v2/src/modules/workflows/inputs/update-workflow.input.ts @@ -36,6 +36,16 @@ import { AFTER_GUESTS_CAL_VIDEO_NO_SHOW, OnAfterCalVideoHostsNoShowTriggerDto, AFTER_HOSTS_CAL_VIDEO_NO_SHOW, + OnNoShowUpdateTriggerDto, + OnRejectedTriggerDto, + OnRequestedTriggerDto, + OnPaymentInitiatedTriggerDto, + OnPaidTriggerDto, + BOOKING_REQUESTED, + BOOKING_REJECTED, + BOOKING_PAYMENT_INITIATED, + BOOKING_PAID, + BOOKING_NO_SHOW_UPDATED, } from "./workflow-trigger.input"; export type UpdateWorkflowStepDto = @@ -119,6 +129,11 @@ export class UpdateWhatsAppAttendeePhoneWorkflowStepDto extends WorkflowPhoneWha OnCancelTriggerDto, OnCreationTriggerDto, OnRescheduleTriggerDto, + OnNoShowUpdateTriggerDto, + OnRejectedTriggerDto, + OnRequestedTriggerDto, + OnPaymentInitiatedTriggerDto, + OnPaidTriggerDto, OnAfterCalVideoGuestsNoShowTriggerDto, OnAfterCalVideoHostsNoShowTriggerDto, UpdateEmailAddressWorkflowStepDto, @@ -155,6 +170,11 @@ export class UpdateWorkflowDto { { $ref: getSchemaPath(OnRescheduleTriggerDto) }, { $ref: getSchemaPath(OnAfterCalVideoGuestsNoShowTriggerDto) }, { $ref: getSchemaPath(OnAfterCalVideoHostsNoShowTriggerDto) }, + { $ref: getSchemaPath(OnRejectedTriggerDto) }, + { $ref: getSchemaPath(OnRequestedTriggerDto) }, + { $ref: getSchemaPath(OnPaidTriggerDto) }, + { $ref: getSchemaPath(OnPaymentInitiatedTriggerDto) }, + { $ref: getSchemaPath(OnNoShowUpdateTriggerDto) }, ], }) @IsOptional() @@ -170,6 +190,11 @@ export class UpdateWorkflowDto { { value: OnRescheduleTriggerDto, name: RESCHEDULE_EVENT }, { value: OnAfterCalVideoGuestsNoShowTriggerDto, name: AFTER_GUESTS_CAL_VIDEO_NO_SHOW }, { value: OnAfterCalVideoHostsNoShowTriggerDto, name: AFTER_HOSTS_CAL_VIDEO_NO_SHOW }, + { value: OnRequestedTriggerDto, name: BOOKING_REQUESTED }, + { value: OnRejectedTriggerDto, name: BOOKING_REJECTED }, + { value: OnPaymentInitiatedTriggerDto, name: BOOKING_PAYMENT_INITIATED }, + { value: OnPaidTriggerDto, name: BOOKING_PAID }, + { value: OnNoShowUpdateTriggerDto, name: BOOKING_NO_SHOW_UPDATED }, ], }, }) @@ -179,6 +204,11 @@ export class UpdateWorkflowDto { | OnCreationTriggerDto | OnRescheduleTriggerDto | OnCancelTriggerDto + | OnRejectedTriggerDto + | OnRequestedTriggerDto + | OnPaidTriggerDto + | OnPaymentInitiatedTriggerDto + | OnNoShowUpdateTriggerDto | OnAfterCalVideoGuestsNoShowTriggerDto | OnAfterCalVideoHostsNoShowTriggerDto; diff --git a/apps/api/v2/src/modules/workflows/inputs/workflow-trigger.input.ts b/apps/api/v2/src/modules/workflows/inputs/workflow-trigger.input.ts index 84d3d8152b..8eb62ab401 100644 --- a/apps/api/v2/src/modules/workflows/inputs/workflow-trigger.input.ts +++ b/apps/api/v2/src/modules/workflows/inputs/workflow-trigger.input.ts @@ -10,6 +10,11 @@ export const AFTER_EVENT = "afterEvent"; export const RESCHEDULE_EVENT = "rescheduleEvent"; export const AFTER_HOSTS_CAL_VIDEO_NO_SHOW = "afterHostsCalVideoNoShow"; export const AFTER_GUESTS_CAL_VIDEO_NO_SHOW = "afterGuestsCalVideoNoShow"; +export const BOOKING_REJECTED = "bookingRejected"; +export const BOOKING_REQUESTED = "bookingRequested"; +export const BOOKING_PAYMENT_INITIATED = "bookingPaymentInitiated"; +export const BOOKING_PAID = "bookingPaid"; +export const BOOKING_NO_SHOW_UPDATED = "bookingNoShowUpdated"; export const WORKFLOW_TRIGGER_TYPES = [ BEFORE_EVENT, EVENT_CANCELLED, @@ -18,6 +23,11 @@ export const WORKFLOW_TRIGGER_TYPES = [ RESCHEDULE_EVENT, AFTER_HOSTS_CAL_VIDEO_NO_SHOW, AFTER_GUESTS_CAL_VIDEO_NO_SHOW, + BOOKING_REJECTED, + BOOKING_REQUESTED, + BOOKING_PAYMENT_INITIATED, + BOOKING_PAID, + BOOKING_NO_SHOW_UPDATED, ] as const; export const WORKFLOW_TRIGGER_TO_ENUM = { @@ -28,6 +38,11 @@ export const WORKFLOW_TRIGGER_TO_ENUM = { [RESCHEDULE_EVENT]: WorkflowTriggerEvents.RESCHEDULE_EVENT, [AFTER_HOSTS_CAL_VIDEO_NO_SHOW]: WorkflowTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW, [AFTER_GUESTS_CAL_VIDEO_NO_SHOW]: WorkflowTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW, + [BOOKING_REJECTED]: WorkflowTriggerEvents.BOOKING_REJECTED, + [BOOKING_REQUESTED]: WorkflowTriggerEvents.BOOKING_REQUESTED, + [BOOKING_PAYMENT_INITIATED]: WorkflowTriggerEvents.BOOKING_PAYMENT_INITIATED, + [BOOKING_NO_SHOW_UPDATED]: WorkflowTriggerEvents.BOOKING_NO_SHOW_UPDATED, + [BOOKING_PAID]: WorkflowTriggerEvents.BOOKING_PAID, } as const; export const ENUM_TO_WORKFLOW_TRIGGER = { @@ -38,6 +53,11 @@ export const ENUM_TO_WORKFLOW_TRIGGER = { [WorkflowTriggerEvents.RESCHEDULE_EVENT]: RESCHEDULE_EVENT, [WorkflowTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW]: AFTER_HOSTS_CAL_VIDEO_NO_SHOW, [WorkflowTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW]: AFTER_GUESTS_CAL_VIDEO_NO_SHOW, + [WorkflowTriggerEvents.BOOKING_REJECTED]: BOOKING_REJECTED, + [WorkflowTriggerEvents.BOOKING_REQUESTED]: BOOKING_REQUESTED, + [WorkflowTriggerEvents.BOOKING_PAYMENT_INITIATED]: BOOKING_PAYMENT_INITIATED, + [WorkflowTriggerEvents.BOOKING_PAID]: BOOKING_PAID, + [WorkflowTriggerEvents.BOOKING_NO_SHOW_UPDATED]: BOOKING_NO_SHOW_UPDATED, } as const; export const HOUR = "hour"; @@ -108,6 +128,41 @@ export class OnCancelTriggerDto { type: typeof EVENT_CANCELLED = EVENT_CANCELLED; } +export class OnRejectedTriggerDto { + @ApiProperty({ + description: "Trigger type for the workflow", + }) + type: typeof BOOKING_REJECTED = BOOKING_REJECTED; +} + +export class OnRequestedTriggerDto { + @ApiProperty({ + description: "Trigger type for the workflow", + }) + type: typeof BOOKING_REQUESTED = BOOKING_REQUESTED; +} + +export class OnPaymentInitiatedTriggerDto { + @ApiProperty({ + description: "Trigger type for the workflow", + }) + type: typeof BOOKING_PAYMENT_INITIATED = BOOKING_PAYMENT_INITIATED; +} + +export class OnPaidTriggerDto { + @ApiProperty({ + description: "Trigger type for the workflow", + }) + type: typeof BOOKING_PAID = BOOKING_PAID; +} + +export class OnNoShowUpdateTriggerDto { + @ApiProperty({ + description: "Trigger type for the workflow", + }) + type: typeof BOOKING_NO_SHOW_UPDATED = BOOKING_NO_SHOW_UPDATED; +} + export class TriggerOffsetDTO { @ApiProperty({ description: "Offset before/after the trigger time; required for BEFORE_EVENT and AFTER_EVENT only", diff --git a/apps/api/v2/swagger/documentation.json b/apps/api/v2/swagger/documentation.json index 327b23877a..af0e2fe361 100644 --- a/apps/api/v2/swagger/documentation.json +++ b/apps/api/v2/swagger/documentation.json @@ -22554,7 +22554,12 @@ "afterEvent", "rescheduleEvent", "afterHostsCalVideoNoShow", - "afterGuestsCalVideoNoShow" + "afterGuestsCalVideoNoShow", + "bookingRejected", + "bookingRequested", + "bookingPaymentInitiated", + "bookingPaid", + "bookingNoShowUpdated" ] }, "offset": { @@ -22923,6 +22928,71 @@ "type" ] }, + "OnNoShowUpdateTriggerDto": { + "type": "object", + "properties": { + "type": { + "type": "string", + "default": "bookingNoShowUpdated", + "description": "Trigger type for the workflow" + } + }, + "required": [ + "type" + ] + }, + "OnRejectedTriggerDto": { + "type": "object", + "properties": { + "type": { + "type": "string", + "default": "bookingRejected", + "description": "Trigger type for the workflow" + } + }, + "required": [ + "type" + ] + }, + "OnRequestedTriggerDto": { + "type": "object", + "properties": { + "type": { + "type": "string", + "default": "bookingRequested", + "description": "Trigger type for the workflow" + } + }, + "required": [ + "type" + ] + }, + "OnPaymentInitiatedTriggerDto": { + "type": "object", + "properties": { + "type": { + "type": "string", + "default": "bookingPaymentInitiated", + "description": "Trigger type for the workflow" + } + }, + "required": [ + "type" + ] + }, + "OnPaidTriggerDto": { + "type": "object", + "properties": { + "type": { + "type": "string", + "default": "bookingPaid", + "description": "Trigger type for the workflow" + } + }, + "required": [ + "type" + ] + }, "OnAfterCalVideoGuestsNoShowTriggerDto": { "type": "object", "properties": { @@ -23642,6 +23712,21 @@ }, { "$ref": "#/components/schemas/OnAfterCalVideoHostsNoShowTriggerDto" + }, + { + "$ref": "#/components/schemas/OnRejectedTriggerDto" + }, + { + "$ref": "#/components/schemas/OnRequestedTriggerDto" + }, + { + "$ref": "#/components/schemas/OnPaidTriggerDto" + }, + { + "$ref": "#/components/schemas/OnPaymentInitiatedTriggerDto" + }, + { + "$ref": "#/components/schemas/OnNoShowUpdateTriggerDto" } ] }, @@ -24298,6 +24383,21 @@ }, { "$ref": "#/components/schemas/OnAfterCalVideoHostsNoShowTriggerDto" + }, + { + "$ref": "#/components/schemas/OnRejectedTriggerDto" + }, + { + "$ref": "#/components/schemas/OnRequestedTriggerDto" + }, + { + "$ref": "#/components/schemas/OnPaidTriggerDto" + }, + { + "$ref": "#/components/schemas/OnPaymentInitiatedTriggerDto" + }, + { + "$ref": "#/components/schemas/OnNoShowUpdateTriggerDto" } ] }, diff --git a/apps/web/playwright/fixtures/workflows.ts b/apps/web/playwright/fixtures/workflows.ts index acc8f23a70..fa6e6d3a4a 100644 --- a/apps/web/playwright/fixtures/workflows.ts +++ b/apps/web/playwright/fixtures/workflows.ts @@ -3,6 +3,7 @@ import { expect, type Page } from "@playwright/test"; import prisma from "@calcom/prisma"; import { WorkflowTriggerEvents } from "@calcom/prisma/enums"; +import type { Fixtures } from "@calcom/web/playwright/lib/fixtures"; import { localize } from "../lib/localize"; @@ -12,6 +13,8 @@ type CreateWorkflowProps = { trigger?: WorkflowTriggerEvents; }; +const subjectPattern = /^Reminder: /i; + export function createWorkflowPageFixture(page: Page) { const createWorkflow = async (props: CreateWorkflowProps) => { const { name, isTeam, trigger } = props; @@ -25,8 +28,8 @@ export function createWorkflowPageFixture(page: Page) { await fillNameInput(name); } if (trigger) { - page.locator("div").filter({ hasText: WorkflowTriggerEvents.BEFORE_EVENT }).nth(1); - page.getByText(trigger); + await page.locator("#trigger-select").click(); + await page.getByTestId(`select-option-${trigger ?? WorkflowTriggerEvents.BEFORE_EVENT}`).click(); await selectEventType("30 min"); } const workflow = await saveWorkflow(); @@ -112,6 +115,20 @@ export function createWorkflowPageFixture(page: Page) { expect(deleteButton.isDisabled()).toBeTruthy(); }; + const assertWorkflowWasTriggered = async (emails: Fixtures["emails"], emailsToBeReceived: string[]) => { + const message = await emails.messages(); + emailsToBeReceived.forEach((email) => { + expect(message?.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + subject: expect.stringMatching(subjectPattern), + to: email, + }), + ]) + ); + }); + }; + const assertWorkflowReminders = async (eventTypeId: number, count: number) => { const booking = await prisma.booking.findFirst({ where: { @@ -143,5 +160,6 @@ export function createWorkflowPageFixture(page: Page) { selectedWorkflowPage, workflowOptionsAreDisabled, assertWorkflowReminders, + assertWorkflowWasTriggered, }; } diff --git a/apps/web/playwright/workflow.e2e.ts b/apps/web/playwright/workflow.e2e.ts index 96a0595d56..b8b87cd0aa 100644 --- a/apps/web/playwright/workflow.e2e.ts +++ b/apps/web/playwright/workflow.e2e.ts @@ -7,7 +7,7 @@ import { bookEventOnThisPage } from "./lib/testUtils"; test.describe("Workflow Tab - Event Type", () => { test.describe("Check the functionalities of the Workflow Tab", () => { test.describe("User Workflows", () => { - test.beforeEach(async ({ page, users }) => { + test.beforeEach(async ({ page, users, emails }) => { await loginUser(users); await page.goto("/workflows"); }); @@ -42,16 +42,28 @@ test.describe("Workflow Tab - Event Type", () => { await assertListCount(1); }); - test("Create an action and check if workflow is triggered", async ({ page, users, workflowPage }) => { + test("Create an action and check if workflow reminder is added", async ({ + page, + users, + workflowPage, + emails, + }) => { const { createWorkflow, assertWorkflowReminders } = workflowPage; const [user] = users.get(); const [eventType] = user.eventTypes; - await createWorkflow({ name: "A New Workflow", trigger: WorkflowTriggerEvents.NEW_EVENT }); + await createWorkflow({ + name: "A New Before Event Workflow", + trigger: WorkflowTriggerEvents.BEFORE_EVENT, + }); + await createWorkflow({ + name: "A New After Event Workflow", + trigger: WorkflowTriggerEvents.AFTER_EVENT, + }); await page.goto(`/${user.username}/${eventType.slug}`); await page.click('[data-testid="incrementMonth"]'); await bookEventOnThisPage(page); - await assertWorkflowReminders(eventType.id, 1); + await assertWorkflowReminders(eventType.id, 2); }); }); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 19633f93fd..207f1f530e 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -1855,6 +1855,11 @@ "require_additional_notes_description": "Require additional notes to be filled out when booking", "email_address_action": "send email to a specific email address", "after_event_trigger": "after event ends", + "booking_rejected_trigger": "when booking is rejected", + "booking_requested_trigger": "when booking is requested", + "booking_payment_initiated_trigger": "when booking payment is initiated", + "booking_paid_trigger": "when booking payment is successful", + "booking_no_show_updated_trigger": "when booking no-show is updated", "how_long_after": "How long after event ends?", "how_long_after_hosts_no_show": "How long after hosts don't show up on cal video meeting?", "how_long_after_guests_no_show": "How long after guests don't show up on cal video meeting?", diff --git a/packages/features/bookings/lib/handleBookingRequested.ts b/packages/features/bookings/lib/handleBookingRequested.ts index 5c3c321fc3..41c8e8f0c6 100644 --- a/packages/features/bookings/lib/handleBookingRequested.ts +++ b/packages/features/bookings/lib/handleBookingRequested.ts @@ -2,13 +2,16 @@ import type { Prisma } from "@prisma/client"; import { sendAttendeeRequestEmailAndSMS, sendOrganizerRequestEmail } from "@calcom/emails"; import { getWebhookPayloadForBooking } from "@calcom/features/bookings/lib/getWebhookPayloadForBooking"; +import type { Workflow } from "@calcom/features/ee/workflows/lib/types"; import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload"; import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; -import { WebhookTriggerEvents } from "@calcom/prisma/enums"; +import { WorkflowService } from "@calcom/lib/server/service/workflows"; +import { WebhookTriggerEvents, WorkflowTriggerEvents } from "@calcom/prisma/enums"; import type { EventTypeMetadata } from "@calcom/prisma/zod-utils"; +import { getAllWorkflowsFromEventType } from "@calcom/trpc/server/routers/viewer/workflows/util"; import type { CalendarEvent } from "@calcom/types/Calendar"; const log = logger.getSubLogger({ prefix: ["[handleBookingRequested] book:user"] }); @@ -19,11 +22,26 @@ const log = logger.getSubLogger({ prefix: ["[handleBookingRequested] book:user"] export async function handleBookingRequested(args: { evt: CalendarEvent; booking: { + smsReminderNumber: string | null; eventType: { + workflows: { + workflow: Workflow; + }[]; + owner: { + hideBranding: boolean; + } | null; team?: { parentId: number | null; } | null; currency: string; + hosts?: { + user: { + email: string; + destinationCalendar?: { + primaryEmail: string | null; + } | null; + }; + }[]; description: string | null; id: number; length: number; @@ -83,6 +101,25 @@ export async function handleBookingRequested(args: { }) ); await Promise.all(promises); + + const workflows = await getAllWorkflowsFromEventType(booking.eventType, booking.userId); + if (workflows.length > 0) { + await WorkflowService.scheduleWorkflowsFilteredByTriggerEvent({ + workflows, + smsReminderNumber: booking.smsReminderNumber, + hideBranding: !!booking.eventType?.owner?.hideBranding, + calendarEvent: { + ...evt, + bookerUrl: evt.bookerUrl as string, + eventType: { + slug: evt.type, + hosts: booking.eventType?.hosts, + schedulingType: evt.schedulingType, + }, + }, + triggers: [WorkflowTriggerEvents.BOOKING_REQUESTED], + }); + } } catch (error) { // Silently fail log.error("Error in handleBookingRequested", safeStringify(error)); diff --git a/packages/features/bookings/lib/handleCancelBooking/test/handleCancelBooking.test.ts b/packages/features/bookings/lib/handleCancelBooking/test/handleCancelBooking.test.ts index 0a349f57b8..fdd7eec07b 100644 --- a/packages/features/bookings/lib/handleCancelBooking/test/handleCancelBooking.test.ts +++ b/packages/features/bookings/lib/handleCancelBooking/test/handleCancelBooking.test.ts @@ -10,7 +10,10 @@ import { TestData, getDate, } from "@calcom/web/test/utils/bookingScenario/bookingScenario"; -import { expectBookingCancelledWebhookToHaveBeenFired } from "@calcom/web/test/utils/bookingScenario/expects"; +import { + expectBookingCancelledWebhookToHaveBeenFired, + expectWorkflowToBeTriggered, +} from "@calcom/web/test/utils/bookingScenario/expects"; import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown"; import { describe, expect, vi } from "vitest"; @@ -26,7 +29,7 @@ vi.mock("@calcom/lib/payment/processPaymentRefund", () => ({ describe("Cancel Booking", () => { setupAndTeardown(); - test("Should trigger BOOKING_CANCELLED webhook", async () => { + test("Should trigger BOOKING_CANCELLED webhook and workflow", async ({ emails }) => { const handleCancelBooking = (await import("@calcom/features/bookings/lib/handleCancelBooking")).default; const booker = getBooker({ @@ -59,6 +62,15 @@ describe("Cancel Booking", () => { appId: null, }, ], + workflows: [ + { + userId: organizer.id, + trigger: "EVENT_CANCELLED", + action: "EMAIL_HOST", + template: "REMINDER", + activeOn: [1], + }, + ], eventTypes: [ { id: 1, @@ -75,6 +87,12 @@ describe("Cancel Booking", () => { { id: idOfBookingToBeCancelled, uid: uidOfBookingToBeCancelled, + attendees: [ + { + email: booker.email, + timeZone: "Asia/Kolkata", + }, + ], eventTypeId: 1, userId: 101, responses: { @@ -133,6 +151,8 @@ describe("Cancel Booking", () => { }, }, }); + + expectWorkflowToBeTriggered({ emailsToReceive: [organizer.email], emails }); }); test("Should call processPaymentRefund", async () => { diff --git a/packages/features/bookings/lib/handleConfirmation.ts b/packages/features/bookings/lib/handleConfirmation.ts index f1b62db10d..bab5539846 100644 --- a/packages/features/bookings/lib/handleConfirmation.ts +++ b/packages/features/bookings/lib/handleConfirmation.ts @@ -6,7 +6,6 @@ import { allowDisablingAttendeeConfirmationEmails, allowDisablingHostConfirmationEmails, } from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails"; -import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler"; import type { Workflow } from "@calcom/features/ee/workflows/lib/types"; import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; import { scheduleTrigger } from "@calcom/features/webhooks/lib/scheduleTrigger"; @@ -20,9 +19,10 @@ import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId"; import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; +import { WorkflowService } from "@calcom/lib/server/service/workflows"; import type { PrismaClient } from "@calcom/prisma"; import type { SchedulingType } from "@calcom/prisma/enums"; -import { BookingStatus, WebhookTriggerEvents } from "@calcom/prisma/enums"; +import { BookingStatus, WebhookTriggerEvents, WorkflowTriggerEvents } from "@calcom/prisma/enums"; import type { PlatformClientParams } from "@calcom/prisma/zod-utils"; import { EventTypeMetaDataSchema, eventTypeAppMetadataOptionalSchema } from "@calcom/prisma/zod-utils"; import { getAllWorkflowsFromEventType } from "@calcom/trpc/server/routers/viewer/workflows/util"; @@ -362,12 +362,14 @@ export async function handleConfirmation(args: { }); } - await scheduleWorkflowReminders({ + await WorkflowService.scheduleWorkflowsForNewBooking({ workflows, smsReminderNumber: updatedBookings[index].smsReminderNumber, calendarEvent: evtOfBooking, - isFirstRecurringEvent: isFirstBooking, hideBranding: !!updatedBookings[index].eventType?.owner?.hideBranding, + isConfirmedByDefault: true, + isNormalBookingOrFirstRecurringSlot: isFirstBooking, + isRescheduleEvent: false, }); } } catch (error) { @@ -552,6 +554,35 @@ export async function handleConfirmation(args: { // I don't need to await for this Promise.all(bookingPaidSubscribers); + + try { + const calendarEventForWorkflow = { + ...evt, + eventType: { + slug: updatedBookings[0].eventType?.slug || "", + schedulingType: updatedBookings[0].eventType?.schedulingType, + hosts: + updatedBookings[0].eventType?.hosts?.map((host) => ({ + user: { + email: host.user.email, + destinationCalendar: host.user.destinationCalendar, + }, + })) || [], + }, + bookerUrl: bookerUrl, + metadata: { videoCallUrl: meetingUrl }, + }; + + await WorkflowService.scheduleWorkflowsFilteredByTriggerEvent({ + workflows, + smsReminderNumber: booking.smsReminderNumber, + calendarEvent: calendarEventForWorkflow, + hideBranding: !!updatedBookings[0].eventType?.owner?.hideBranding, + triggers: [WorkflowTriggerEvents.BOOKING_PAID], + }); + } catch (error) { + log.error("Error while scheduling workflow reminders for booking paid", safeStringify(error)); + } } } catch (error) { // Silently fail diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index cf0997ebe0..92f5a6a187 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -36,7 +36,6 @@ import { allowDisablingAttendeeConfirmationEmails, allowDisablingHostConfirmationEmails, } from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails"; -import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler"; import type { FeaturesRepository } from "@calcom/features/flags/features.repository"; import { getFullName } from "@calcom/features/form-builder/utils"; import { UsersRepository } from "@calcom/features/users/users.repository"; @@ -79,11 +78,17 @@ import { getTranslation } from "@calcom/lib/server/i18n"; import { BookingRepository } from "@calcom/lib/server/repository/booking"; import { WorkflowRepository } from "@calcom/lib/server/repository/workflow"; import { HashedLinkService } from "@calcom/lib/server/service/hashedLinkService"; +import { WorkflowService } from "@calcom/lib/server/service/workflows"; import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat"; import type { PrismaClient } from "@calcom/prisma"; import prisma from "@calcom/prisma"; import type { AssignmentReasonEnum } from "@calcom/prisma/enums"; -import { BookingStatus, SchedulingType, WebhookTriggerEvents } from "@calcom/prisma/enums"; +import { + BookingStatus, + SchedulingType, + WebhookTriggerEvents, + WorkflowTriggerEvents, +} from "@calcom/prisma/enums"; import { CreationSource } from "@calcom/prisma/enums"; import { eventTypeAppMetadataOptionalSchema, @@ -530,6 +535,9 @@ async function handler( const bookingSeat = reqBody.rescheduleUid ? await getSeatedBooking(reqBody.rescheduleUid) : null; const rescheduleUid = bookingSeat ? bookingSeat.booking.uid : reqBody.rescheduleUid; + const isNormalBookingOrFirstRecurringSlot = input.bookingData.allRecurringDates + ? input.bookingData.isFirstRecurringSlot + : true; let originalRescheduledBooking = rescheduleUid ? await getOriginalRescheduledBooking(rescheduleUid, !!eventType.seatsPerTimeSlot) @@ -2230,6 +2238,37 @@ async function handler( isDryRun, }); + try { + const calendarEventForWorkflow = { + ...evt, + rescheduleReason, + metadata, + eventType: { + slug: eventType.slug, + schedulingType: eventType.schedulingType, + hosts: eventType.hosts, + }, + bookerUrl, + }; + + if (isNormalBookingOrFirstRecurringSlot) { + await WorkflowService.scheduleWorkflowsFilteredByTriggerEvent({ + workflows, + smsReminderNumber: smsReminderNumber || null, + calendarEvent: calendarEventForWorkflow, + hideBranding: !!eventType.owner?.hideBranding, + seatReferenceUid: evt.attendeeSeatId, + isDryRun, + triggers: [WorkflowTriggerEvents.BOOKING_PAYMENT_INITIATED], + }); + } + } catch (error) { + loggerWithEventDetails.error( + "Error while scheduling workflow reminders for booking payment initiated", + JSON.stringify({ error }) + ); + } + // TODO: Refactor better so this booking object is not passed // all around and instead the individual fields are sent as args. const bookingResponse = { @@ -2406,18 +2445,16 @@ async function handler( } try { - await scheduleWorkflowReminders({ + await WorkflowService.scheduleWorkflowsForNewBooking({ workflows, smsReminderNumber: smsReminderNumber || null, calendarEvent: evtWithMetadata, - isNotConfirmed: rescheduleUid ? false : !isConfirmedByDefault, - isRescheduleEvent: !!rescheduleUid, - isFirstRecurringEvent: input.bookingData.allRecurringDates - ? input.bookingData.isFirstRecurringSlot - : undefined, hideBranding: !!eventType.owner?.hideBranding, seatReferenceUid: evt.attendeeSeatId, isDryRun, + isConfirmedByDefault, + isNormalBookingOrFirstRecurringSlot, + isRescheduleEvent: !!rescheduleUid, }); } catch (error) { loggerWithEventDetails.error("Error while scheduling workflow reminders", JSON.stringify({ error })); diff --git a/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts b/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts index 10c0cd461e..2b34b30247 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts @@ -2115,6 +2115,7 @@ describe("handleNewBooking", () => { 1. Should create a booking in the database with status PENDING 2. Should send emails to the booker as well as organizer for booking request and awaiting approval 3. Should trigger BOOKING_REQUESTED webhook + 4. Should trigger BOOKING_REQUESTED workflow `, async ({ emails }) => { const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; @@ -2151,6 +2152,13 @@ describe("handleNewBooking", () => { template: "REMINDER", activeOn: [1], }, + { + userId: organizer.id, + trigger: "BOOKING_REQUESTED", + action: "EMAIL_ATTENDEE", + template: "REMINDER", + activeOn: [1], + }, ], eventTypes: [ { @@ -2217,6 +2225,7 @@ describe("handleNewBooking", () => { }); expectWorkflowToBeNotTriggered({ emailsToReceive: [organizer.email], emails }); + expectWorkflowToBeTriggered({ emailsToReceive: [booker.email], emails }); expectBookingRequestedEmails({ booker, @@ -2243,6 +2252,7 @@ describe("handleNewBooking", () => { 1. Should create a booking in the database with status PENDING 2. Should send emails to the booker as well as organizer for booking request and awaiting approval 3. Should trigger BOOKING_REQUESTED webhook + 4. Should trigger BOOKING_REQUESTED workflow `, async ({ emails }) => { const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; @@ -2279,6 +2289,13 @@ describe("handleNewBooking", () => { template: "REMINDER", activeOn: [1], }, + { + userId: organizer.id, + trigger: "BOOKING_REQUESTED", + action: "EMAIL_ATTENDEE", + template: "REMINDER", + activeOn: [1], + }, ], eventTypes: [ { @@ -2338,6 +2355,7 @@ describe("handleNewBooking", () => { }); expectWorkflowToBeNotTriggered({ emailsToReceive: [organizer.email], emails }); + expectWorkflowToBeTriggered({ emails, emailsToReceive: [booker.email] }); expectBookingRequestedEmails({ booker, @@ -2496,6 +2514,7 @@ describe("handleNewBooking", () => { 1. Should create a booking in the database with status PENDING 2. Should send emails to the booker as well as organizer for booking request and awaiting approval 3. Should trigger BOOKING_REQUESTED webhook + 4. Should trigger BOOKING_REQUESTED workflows `, async ({ emails }) => { const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; @@ -2532,6 +2551,13 @@ describe("handleNewBooking", () => { template: "REMINDER", activeOn: [1], }, + { + userId: organizer.id, + trigger: "BOOKING_REQUESTED", + action: "EMAIL_ATTENDEE", + template: "REMINDER", + activeOn: [1], + }, ], eventTypes: [ { @@ -2601,6 +2627,7 @@ describe("handleNewBooking", () => { }); expectWorkflowToBeNotTriggered({ emailsToReceive: [organizer.email], emails }); + expectWorkflowToBeTriggered({ emailsToReceive: [booker.email], emails }); expectBookingRequestedEmails({ booker, organizer, emails }); @@ -2808,9 +2835,10 @@ describe("handleNewBooking", () => { 1. Should create a booking in the database with status PENDING 2. Should send email to the booker for Payment request 3. Should trigger BOOKING_PAYMENT_INITIATED webhook - 4. Once payment is successful, should trigger BOOKING_CREATED webhook - 5. Workflow should not trigger before payment is made - 6. Workflow triggers once payment is successful + 4. Should trigger BOOKING_PAYMENT_INITIATED workflow + 5. Once payment is successful, should trigger BOOKING_CREATED webhook + 6. Workflow should not trigger before payment is made + 7. Workflow triggers once payment is successful `, async ({ emails }) => { const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; @@ -2819,6 +2847,8 @@ describe("handleNewBooking", () => { name: "Booker", }); + const bookingInitiatedEmail = "booking_initiated@workflow.com"; + const bookingPaidEmail = "booking_paid@workflows.com"; const organizer = getOrganizer({ name: "Organizer", email: "organizer@example.com", @@ -2846,6 +2876,24 @@ describe("handleNewBooking", () => { template: "REMINDER", activeOn: [1], }, + { + userId: organizer.id, + trigger: "BOOKING_PAYMENT_INITIATED", + action: "EMAIL_ADDRESS", + sendTo: bookingInitiatedEmail, + verifiedAt: new Date("2023-01-01T00:00:00.000Z"), + template: "REMINDER", + activeOn: [1], + }, + { + userId: organizer.id, + trigger: "BOOKING_PAID", + action: "EMAIL_ADDRESS", + sendTo: bookingPaidEmail, + verifiedAt: new Date("2023-01-01T00:00:00.000Z"), + template: "REMINDER", + activeOn: [1], + }, ], eventTypes: [ { @@ -2923,7 +2971,9 @@ describe("handleNewBooking", () => { }), }); + expectWorkflowToBeNotTriggered({ emailsToReceive: [bookingPaidEmail], emails }); expectWorkflowToBeNotTriggered({ emailsToReceive: [organizer.email], emails }); + expectWorkflowToBeTriggered({ emailsToReceive: [bookingInitiatedEmail], emails }); expectAwaitingPaymentEmails({ organizer, booker, emails }); @@ -2957,6 +3007,7 @@ describe("handleNewBooking", () => { videoCallUrl: `${WEBAPP_URL}/video/${createdBooking.uid}`, paidEvent: true, }); + expectWorkflowToBeTriggered({ emailsToReceive: [bookingPaidEmail], emails }); }, timeout ); @@ -2966,10 +3017,13 @@ describe("handleNewBooking", () => { 1. Should create a booking in the database with status PENDING 2. Should send email to the booker for Payment request 3. Should trigger BOOKING_PAYMENT_INITIATED webhook - 4. Once payment is successful, should trigger BOOKING_REQUESTED webhook - 5. Booking should still stay in pending state + 4. Should trigger BOOKING_PAYMENT_INITIATED workflow + 5. Once payment is successful, should trigger BOOKING_REQUESTED webhook + 6. Should trigger BOOKING_REQUESTED workflow + 7. Booking should still stay in pending state `, async ({ emails }) => { + const bookingInitiatedEmail = "booking_initiated@workflow.com"; const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; const subscriberUrl = "http://my-webhook.example.com"; const booker = getBooker({ @@ -3005,6 +3059,22 @@ describe("handleNewBooking", () => { template: "REMINDER", activeOn: [1], }, + { + userId: organizer.id, + trigger: "BOOKING_PAYMENT_INITIATED", + verifiedAt: new Date("2023-01-01T00:00:00.000Z"), + action: "EMAIL_ADDRESS", + sendTo: bookingInitiatedEmail, + template: "REMINDER", + activeOn: [1], + }, + { + userId: organizer.id, + trigger: "BOOKING_REQUESTED", + action: "EMAIL_ATTENDEE", + template: "REMINDER", + activeOn: [1], + }, ], eventTypes: [ { @@ -3080,6 +3150,7 @@ describe("handleNewBooking", () => { }); expectWorkflowToBeNotTriggered({ emailsToReceive: [organizer.email], emails }); + expectWorkflowToBeNotTriggered({ emailsToReceive: [booker.email], emails }); expectAwaitingPaymentEmails({ organizer, @@ -3095,6 +3166,7 @@ describe("handleNewBooking", () => { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion paymentId: createdBooking.paymentId!, }); + expectWorkflowToBeTriggered({ emailsToReceive: [bookingInitiatedEmail], emails }); // FIXME: Right now we need to reset the test Emails because email expects only tests first email content for an email address // Reset Test Emails to test for more Emails @@ -3123,6 +3195,7 @@ describe("handleNewBooking", () => { paidEvent: true, eventType: scenarioData.eventTypes[0], }); + expectWorkflowToBeTriggered({ emailsToReceive: [booker.email], emails }); }, timeout ); diff --git a/packages/features/bookings/lib/handleSeats/handleSeats.ts b/packages/features/bookings/lib/handleSeats/handleSeats.ts index 59c99e11fc..be2c592392 100644 --- a/packages/features/bookings/lib/handleSeats/handleSeats.ts +++ b/packages/features/bookings/lib/handleSeats/handleSeats.ts @@ -1,10 +1,10 @@ // eslint-disable-next-line no-restricted-imports import dayjs from "@calcom/dayjs"; import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhookTrigger"; -import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler"; import type { EventPayloadType } from "@calcom/features/webhooks/lib/sendPayload"; import { ErrorCode } from "@calcom/lib/errorCodes"; import { HttpError } from "@calcom/lib/http-error"; +import { WorkflowService } from "@calcom/lib/server/service/workflows"; import prisma from "@calcom/prisma"; import { BookingStatus } from "@calcom/prisma/enums"; @@ -108,8 +108,8 @@ const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => { ...reqBodyMetadata, }; try { - await scheduleWorkflowReminders({ - workflows, + await WorkflowService.scheduleWorkflowsForNewBooking({ + workflows: workflows, smsReminderNumber: smsReminderNumber || null, calendarEvent: { ...evt, @@ -124,12 +124,12 @@ const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => { }, }, }, - isNotConfirmed: evt.requiresConfirmation || false, - isRescheduleEvent: !!rescheduleUid, - isFirstRecurringEvent: true, emailAttendeeSendToOverride: bookerEmail, seatReferenceUid: resultBooking?.seatReferenceUid, isDryRun, + isConfirmedByDefault: !evt.requiresConfirmation, + isRescheduleEvent: !!rescheduleUid, + isNormalBookingOrFirstRecurringSlot: true, }); } catch (error) { loggerWithEventDetails.error("Error while scheduling workflow reminders", JSON.stringify({ error })); diff --git a/packages/features/ee/workflows/lib/constants.ts b/packages/features/ee/workflows/lib/constants.ts index 2114975de7..617e21adeb 100644 --- a/packages/features/ee/workflows/lib/constants.ts +++ b/packages/features/ee/workflows/lib/constants.ts @@ -8,6 +8,11 @@ export const WORKFLOW_TRIGGER_EVENTS = [ WorkflowTriggerEvents.RESCHEDULE_EVENT, WorkflowTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW, WorkflowTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW, + WorkflowTriggerEvents.BOOKING_REJECTED, + WorkflowTriggerEvents.BOOKING_REQUESTED, + WorkflowTriggerEvents.BOOKING_PAYMENT_INITIATED, + WorkflowTriggerEvents.BOOKING_PAID, + WorkflowTriggerEvents.BOOKING_NO_SHOW_UPDATED, ] as const; export const WORKFLOW_ACTIONS = [ @@ -79,3 +84,14 @@ export const FORMATTED_DYNAMIC_TEXT_VARIABLES = [ "event_start_time_in_attendee_timezone_", "event_end_time_in_attendee_timezone_", ]; + +export const IMMEDIATE_WORKFLOW_TRIGGER_EVENTS: WorkflowTriggerEvents[] = [ + WorkflowTriggerEvents.NEW_EVENT, + WorkflowTriggerEvents.EVENT_CANCELLED, + WorkflowTriggerEvents.RESCHEDULE_EVENT, + WorkflowTriggerEvents.BOOKING_NO_SHOW_UPDATED, + WorkflowTriggerEvents.BOOKING_PAID, + WorkflowTriggerEvents.BOOKING_PAYMENT_INITIATED, + WorkflowTriggerEvents.BOOKING_REJECTED, + WorkflowTriggerEvents.BOOKING_REQUESTED, +]; diff --git a/packages/features/ee/workflows/lib/reminders/emailReminderManager.ts b/packages/features/ee/workflows/lib/reminders/emailReminderManager.ts index 38fa155114..819f018ec9 100644 --- a/packages/features/ee/workflows/lib/reminders/emailReminderManager.ts +++ b/packages/features/ee/workflows/lib/reminders/emailReminderManager.ts @@ -19,6 +19,7 @@ import { } from "@calcom/prisma/enums"; import { bookingMetadataSchema } from "@calcom/prisma/zod-utils"; +import { IMMEDIATE_WORKFLOW_TRIGGER_EVENTS } from "../constants"; import { getWorkflowRecipientEmail } from "../getWorkflowReminders"; import { sendOrScheduleWorkflowEmails } from "./providers/emailProvider"; import { getBatchId, sendSendgridMail } from "./providers/sendgridProvider"; @@ -323,11 +324,7 @@ export const scheduleEmailReminder = async (args: scheduleEmailReminderArgs) => /** * @deprecated only needed for SendGrid, use SMTP with tasker instead */ - if ( - triggerEvent === WorkflowTriggerEvents.NEW_EVENT || - triggerEvent === WorkflowTriggerEvents.EVENT_CANCELLED || - triggerEvent === WorkflowTriggerEvents.RESCHEDULE_EVENT - ) { + if (IMMEDIATE_WORKFLOW_TRIGGER_EVENTS.includes(triggerEvent)) { try { const promises = sendTo.map((email) => sendSendgridMail({ ...mailData, to: email })); // TODO: Maybe don't await for this? diff --git a/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts b/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts index d7e37c462f..aafec88f36 100644 --- a/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts +++ b/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts @@ -45,9 +45,6 @@ type ProcessWorkflowStepParams = { export interface ScheduleWorkflowRemindersArgs extends ProcessWorkflowStepParams { workflows: Workflow[]; - isNotConfirmed?: boolean; - isRescheduleEvent?: boolean; - isFirstRecurringEvent?: boolean; isDryRun?: boolean; } @@ -204,37 +201,16 @@ const _scheduleWorkflowReminders = async (args: ScheduleWorkflowRemindersArgs) = workflows, smsReminderNumber, calendarEvent: evt, - isNotConfirmed = false, - isRescheduleEvent = false, - isFirstRecurringEvent = true, emailAttendeeSendToOverride = "", hideBranding, seatReferenceUid, isDryRun = false, } = args; - if (isDryRun) return; - if (isNotConfirmed || !workflows.length) return; + if (isDryRun || !workflows.length) return; for (const workflow of workflows) { if (workflow.steps.length === 0) continue; - const isNotBeforeOrAfterEvent = - workflow.trigger !== WorkflowTriggerEvents.BEFORE_EVENT && - workflow.trigger !== WorkflowTriggerEvents.AFTER_EVENT; - - if ( - isNotBeforeOrAfterEvent && - // Check if the trigger is not a new event without a reschedule and is the first recurring event. - !( - workflow.trigger === WorkflowTriggerEvents.NEW_EVENT && - !isRescheduleEvent && - isFirstRecurringEvent - ) && - // Check if the trigger is not a rescheduled event that is rescheduled. - !(workflow.trigger === WorkflowTriggerEvents.RESCHEDULE_EVENT && isRescheduleEvent) - ) { - continue; - } for (const step of workflow.steps) { await processWorkflowStep(workflow, step, { calendarEvent: evt, diff --git a/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts b/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts index ab88af55ed..de1542a0c8 100644 --- a/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts +++ b/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts @@ -18,6 +18,7 @@ import type { CalEventResponses, RecurringEvent } from "@calcom/types/Calendar"; import { isAttendeeAction } from "../actionHelperFunctions"; import { getSenderId } from "../alphanumericSenderIdSupport"; +import { IMMEDIATE_WORKFLOW_TRIGGER_EVENTS } from "../constants"; import { WorkflowOptOutContactRepository } from "../repository/workflowOptOutContact"; import { WorkflowOptOutService } from "../service/workflowOptOutService"; import type { ScheduleReminderArgs } from "./emailReminderManager"; @@ -187,11 +188,7 @@ export const scheduleSMSReminder = async (args: ScheduleTextReminderArgs) => { // Allows debugging generated email content without waiting for sendgrid to send emails log.debug(`Sending sms for trigger ${triggerEvent}`, smsMessage); - if ( - triggerEvent === WorkflowTriggerEvents.NEW_EVENT || - triggerEvent === WorkflowTriggerEvents.EVENT_CANCELLED || - triggerEvent === WorkflowTriggerEvents.RESCHEDULE_EVENT - ) { + if (IMMEDIATE_WORKFLOW_TRIGGER_EVENTS.includes(triggerEvent)) { try { await sendSmsOrFallbackEmail({ twilioData: { diff --git a/packages/features/ee/workflows/lib/reminders/utils.ts b/packages/features/ee/workflows/lib/reminders/utils.ts index 2ff24bcf0b..e152975989 100644 --- a/packages/features/ee/workflows/lib/reminders/utils.ts +++ b/packages/features/ee/workflows/lib/reminders/utils.ts @@ -4,6 +4,7 @@ import { WEBSITE_URL } from "@calcom/lib/constants"; import { WorkflowActions, WorkflowTriggerEvents } from "@calcom/prisma/enums"; import { bookingMetadataSchema } from "@calcom/prisma/zod-utils"; +import { IMMEDIATE_WORKFLOW_TRIGGER_EVENTS } from "../constants"; import { getWorkflowRecipientEmail } from "../getWorkflowReminders"; import type { AttendeeInBookingInfo, BookingInfo } from "./smsReminderManager"; import type { VariablesType } from "./templates/customTemplate"; @@ -115,11 +116,7 @@ export const getAttendeeToBeUsedInSMS = ( }; export const shouldUseTwilio = (trigger: WorkflowTriggerEvents, scheduledDate: dayjs.Dayjs | null) => { - if ( - trigger === WorkflowTriggerEvents.NEW_EVENT || - trigger === WorkflowTriggerEvents.EVENT_CANCELLED || - trigger === WorkflowTriggerEvents.RESCHEDULE_EVENT - ) { + if (IMMEDIATE_WORKFLOW_TRIGGER_EVENTS.includes(trigger)) { return true; } diff --git a/packages/features/ee/workflows/lib/reminders/whatsappReminderManager.ts b/packages/features/ee/workflows/lib/reminders/whatsappReminderManager.ts index 35bbb58fbc..644056dafa 100644 --- a/packages/features/ee/workflows/lib/reminders/whatsappReminderManager.ts +++ b/packages/features/ee/workflows/lib/reminders/whatsappReminderManager.ts @@ -10,6 +10,7 @@ import { } from "@calcom/prisma/enums"; import { isAttendeeAction } from "../actionHelperFunctions"; +import { IMMEDIATE_WORKFLOW_TRIGGER_EVENTS } from "../constants"; import { getContentSidForTemplate, getContentVariablesForTemplate, @@ -172,11 +173,7 @@ export const scheduleWhatsappReminder = async (args: ScheduleTextReminderArgs) = log.debug(`Sending Whatsapp for trigger ${triggerEvent}`, textMessage); if (textMessage.length > 0 && reminderPhone && isNumberVerified) { //send WHATSAPP when event is booked/cancelled/rescheduled - if ( - triggerEvent === WorkflowTriggerEvents.NEW_EVENT || - triggerEvent === WorkflowTriggerEvents.EVENT_CANCELLED || - triggerEvent === WorkflowTriggerEvents.RESCHEDULE_EVENT - ) { + if (IMMEDIATE_WORKFLOW_TRIGGER_EVENTS.includes(triggerEvent)) { try { await sendSmsOrFallbackEmail({ twilioData: { diff --git a/packages/features/handleMarkNoShow.ts b/packages/features/handleMarkNoShow.ts index 764ba63b54..44f541f951 100644 --- a/packages/features/handleMarkNoShow.ts +++ b/packages/features/handleMarkNoShow.ts @@ -1,15 +1,21 @@ import { type TFunction } from "i18next"; +import { workflowSelect } from "@calcom/features/ee/workflows/lib/getAllWorkflows"; +import type { ExtendedCalendarEvent } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler"; import { WebhookService } from "@calcom/features/webhooks/lib/WebhookService"; +import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server"; import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId"; import { HttpError } from "@calcom/lib/http-error"; import logger from "@calcom/lib/logger"; import { getTranslation } from "@calcom/lib/server/i18n"; import { BookingRepository } from "@calcom/lib/server/repository/booking"; +import { WorkflowService } from "@calcom/lib/server/service/workflows"; +import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat"; import { prisma } from "@calcom/prisma"; -import { WebhookTriggerEvents } from "@calcom/prisma/enums"; -import type { PlatformClientParams } from "@calcom/prisma/zod-utils"; +import { WebhookTriggerEvents, WorkflowTriggerEvents } from "@calcom/prisma/enums"; +import { bookingMetadataSchema, type PlatformClientParams } from "@calcom/prisma/zod-utils"; import type { TNoShowInputSchema } from "@calcom/trpc/server/routers/loggedInViewer/markNoShow.schema"; +import { getAllWorkflowsFromEventType } from "@calcom/trpc/server/routers/viewer/workflows/util"; import handleSendingAttendeeNoShowDataToApps from "./noShow/handleSendingAttendeeNoShowDataToApps"; @@ -113,6 +119,193 @@ const handleMarkNoShow = async ({ ...(platformClientParams ? platformClientParams : {}), }); + const booking = await prisma.booking.findUnique({ + where: { uid: bookingUid }, + select: { + startTime: true, + endTime: true, + title: true, + metadata: true, + uid: true, + location: true, + destinationCalendar: true, + smsReminderNumber: true, + userPrimaryEmail: true, + eventType: { + select: { + id: true, + hideOrganizerEmail: true, + customReplyToEmail: true, + schedulingType: true, + slug: true, + title: true, + metadata: true, + parentId: true, + teamId: true, + hosts: { + select: { + user: { + select: { + email: true, + destinationCalendar: { + select: { + primaryEmail: true, + }, + }, + }, + }, + }, + }, + parent: { + select: { + teamId: true, + }, + }, + workflows: { + select: { + workflow: { + select: workflowSelect, + }, + }, + }, + owner: { + select: { + hideBranding: true, + email: true, + name: true, + timeZone: true, + locale: true, + }, + }, + team: { + select: { + parentId: true, + name: true, + id: true, + }, + }, + }, + }, + attendees: { + select: { + email: true, + name: true, + timeZone: true, + locale: true, + phoneNumber: true, + }, + }, + user: { + select: { + id: true, + email: true, + name: true, + destinationCalendar: true, + timeZone: true, + locale: true, + username: true, + timeFormat: true, + }, + }, + }, + }); + + if (booking?.eventType) { + const workflows = await getAllWorkflowsFromEventType(booking.eventType, userId); + + if (workflows.length > 0) { + const tOrganizer = await getTranslation(booking.user?.locale ?? "en", "common"); + // Cache translations to avoid requesting multiple times. + const translations = new Map(); + const attendeesListPromises = booking.attendees.map(async (attendee) => { + const locale = attendee.locale ?? "en"; + let translate = translations.get(locale); + if (!translate) { + translate = await getTranslation(locale, "common"); + translations.set(locale, translate); + } + return { + name: attendee.name, + email: attendee.email, + timeZone: attendee.timeZone, + phoneNumber: attendee.phoneNumber, + language: { + translate, + locale, + }, + }; + }); + const attendeesList = await Promise.all(attendeesListPromises); + try { + const organizer = booking.user || booking.eventType.owner; + const parsedMetadata = bookingMetadataSchema.safeParse(booking.metadata); + const metadata = + parsedMetadata.success && parsedMetadata.data?.videoCallUrl + ? { videoCallUrl: parsedMetadata.data.videoCallUrl } + : undefined; + const bookerUrl = await getBookerBaseUrl(booking.eventType?.team?.parentId ?? null); + const destinationCalendar = booking.destinationCalendar + ? [booking.destinationCalendar] + : booking.user?.destinationCalendar + ? [booking.user?.destinationCalendar] + : []; + const team = !!booking.eventType?.team + ? { + name: booking.eventType.team.name, + id: booking.eventType.team.id, + members: [], + } + : undefined; + + const calendarEvent: ExtendedCalendarEvent = { + type: booking.eventType.slug, + title: booking.title, + startTime: booking.startTime.toISOString(), + endTime: booking.endTime.toISOString(), + organizer: { + id: booking.user?.id, + email: booking?.userPrimaryEmail || booking.user?.email || "Email-less", + name: booking.user?.name || "Nameless", + username: booking.user?.username || undefined, + timeZone: organizer?.timeZone || "UTC", + timeFormat: getTimeFormatStringFromUserTimeFormat(booking.user?.timeFormat), + language: { + translate: tOrganizer, + locale: booking.user?.locale ?? "en", + }, + }, + attendees: attendeesList, + uid: booking.uid, + location: booking.location || "", + eventType: { + slug: booking.eventType.slug, + schedulingType: booking.eventType.schedulingType, + hosts: booking.eventType.hosts, + }, + destinationCalendar, + bookerUrl, + metadata, + rescheduleReason: null, + cancellationReason: null, + hideOrganizerEmail: booking.eventType?.hideOrganizerEmail, + eventTypeId: booking.eventType?.id, + customReplyToEmail: booking.eventType?.customReplyToEmail, + team, + }; + + await WorkflowService.scheduleWorkflowsFilteredByTriggerEvent({ + workflows, + smsReminderNumber: booking.smsReminderNumber, + hideBranding: booking.eventType.owner?.hideBranding, + calendarEvent, + triggers: [WorkflowTriggerEvents.BOOKING_NO_SHOW_UPDATED], + }); + } catch (error) { + logger.error("Error while scheduling workflow reminders for booking no-show updated", error); + } + } + } + responsePayload.setAttendees(payload.attendees); responsePayload.setMessage(payload.message); diff --git a/packages/lib/payment/getBooking.ts b/packages/lib/payment/getBooking.ts index 4e03480871..877c848d0f 100644 --- a/packages/lib/payment/getBooking.ts +++ b/packages/lib/payment/getBooking.ts @@ -36,8 +36,27 @@ export async function getBooking(bookingId: number) { responses: true, eventType: { select: { + owner: { + select: { + hideBranding: true, + }, + }, currency: true, description: true, + hosts: { + select: { + user: { + select: { + email: true, + destinationCalendar: { + select: { + primaryEmail: true, + }, + }, + }, + }, + }, + }, id: true, length: true, price: true, diff --git a/packages/lib/server/service/workflows.ts b/packages/lib/server/service/workflows.ts index 9ff0c2b2b9..db20a71995 100644 --- a/packages/lib/server/service/workflows.ts +++ b/packages/lib/server/service/workflows.ts @@ -1,9 +1,17 @@ +import type { ScheduleWorkflowRemindersArgs } from "@calcom/ee/workflows/lib/reminders/reminderScheduler"; +import { scheduleWorkflowReminders } from "@calcom/ee/workflows/lib/reminders/reminderScheduler"; +import type { Workflow } from "@calcom/ee/workflows/lib/types"; import { prisma } from "@calcom/prisma"; +import { WorkflowTriggerEvents } from "@calcom/prisma/enums"; import { WorkflowRepository } from "../repository/workflow"; // TODO (Sean): Move most of the logic migrated in 16861 to this service export class WorkflowService { + static _beforeAfterEventTriggers: WorkflowTriggerEvents[] = [ + WorkflowTriggerEvents.AFTER_EVENT, + WorkflowTriggerEvents.BEFORE_EVENT, + ]; static async deleteWorkflowRemindersOfRemovedTeam(teamId: number) { const team = await prisma.team.findUnique({ where: { @@ -63,4 +71,61 @@ export class WorkflowService { } } } + + static async scheduleWorkflowsForNewBooking({ + isNormalBookingOrFirstRecurringSlot, + isConfirmedByDefault, + isRescheduleEvent, + workflows, + ...args + }: ScheduleWorkflowRemindersArgs & { + isConfirmedByDefault: boolean; + isRescheduleEvent: boolean; + isNormalBookingOrFirstRecurringSlot: boolean; + }) { + if (workflows.length <= 0) return; + + const workflowsToTrigger: Workflow[] = []; + + if (isRescheduleEvent) { + workflowsToTrigger.push( + ...workflows.filter( + (workflow) => + workflow.trigger === WorkflowTriggerEvents.RESCHEDULE_EVENT || + this._beforeAfterEventTriggers.includes(workflow.trigger) + ) + ); + } else if (!isConfirmedByDefault) { + workflowsToTrigger.push( + ...workflows.filter((workflow) => workflow.trigger === WorkflowTriggerEvents.BOOKING_REQUESTED) + ); + } else if (isConfirmedByDefault) { + workflowsToTrigger.push( + ...workflows.filter( + (workflow) => + this._beforeAfterEventTriggers.includes(workflow.trigger) || + (isNormalBookingOrFirstRecurringSlot && workflow.trigger === WorkflowTriggerEvents.NEW_EVENT) + ) + ); + } + + if (workflowsToTrigger.length === 0) return; + + await scheduleWorkflowReminders({ + ...args, + workflows: workflowsToTrigger, + }); + } + + static async scheduleWorkflowsFilteredByTriggerEvent({ + workflows, + triggers, + ...args + }: ScheduleWorkflowRemindersArgs & { triggers: WorkflowTriggerEvents[] }) { + if (workflows.length <= 0) return; + await scheduleWorkflowReminders({ + ...args, + workflows: workflows.filter((workflow) => triggers.includes(workflow.trigger)), + }); + } } diff --git a/packages/prisma/migrations/20250813182504_adding_booking_triggers_to_workflows/migration.sql b/packages/prisma/migrations/20250813182504_adding_booking_triggers_to_workflows/migration.sql new file mode 100644 index 0000000000..d932c7cb07 --- /dev/null +++ b/packages/prisma/migrations/20250813182504_adding_booking_triggers_to_workflows/migration.sql @@ -0,0 +1,13 @@ +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "WorkflowTriggerEvents" ADD VALUE 'BOOKING_REJECTED'; +ALTER TYPE "WorkflowTriggerEvents" ADD VALUE 'BOOKING_REQUESTED'; +ALTER TYPE "WorkflowTriggerEvents" ADD VALUE 'BOOKING_PAYMENT_INITIATED'; +ALTER TYPE "WorkflowTriggerEvents" ADD VALUE 'BOOKING_PAID'; +ALTER TYPE "WorkflowTriggerEvents" ADD VALUE 'BOOKING_NO_SHOW_UPDATED'; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index ee65190fda..a6731b7364 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -1347,6 +1347,11 @@ enum WorkflowTriggerEvents { RESCHEDULE_EVENT AFTER_HOSTS_CAL_VIDEO_NO_SHOW AFTER_GUESTS_CAL_VIDEO_NO_SHOW + BOOKING_REJECTED + BOOKING_REQUESTED + BOOKING_PAYMENT_INITIATED + BOOKING_PAID + BOOKING_NO_SHOW_UPDATED } enum WorkflowActions { diff --git a/packages/trpc/server/routers/viewer/bookings/confirm.handler.test.ts b/packages/trpc/server/routers/viewer/bookings/confirm.handler.test.ts index 5fc908be6e..06f6b47793 100644 --- a/packages/trpc/server/routers/viewer/bookings/confirm.handler.test.ts +++ b/packages/trpc/server/routers/viewer/bookings/confirm.handler.test.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ // @ts-nocheck // TODO: Bring this test back with the correct setup (no illegal imports) -import { describe, it, beforeEach, vi, expect } from "vitest"; +import { describe, beforeEach, vi, expect, test } from "vitest"; import { BookingStatus } from "@calcom/prisma/enums"; @@ -12,9 +12,12 @@ describe.skip("confirmHandler", () => { beforeEach(() => { // Reset all mocks before each test vi.clearAllMocks(); + // mockNoTranslations(); }); - it("should successfully confirm booking when event type doesn't have any default location", async () => { + test("should successfully confirm booking when event type doesn't have any default location", async ({ + emails, + }) => { const attendeeUser = getOrganizer({ email: "test@example.com", name: "test name", @@ -46,6 +49,15 @@ describe.skip("confirmHandler", () => { appId: null, }, ], + workflows: [ + { + userId: organizer.id, + trigger: "NEW_EVENT", + action: "EMAIL_HOST", + template: "REMINDER", + activeOn: [1], + }, + ], eventTypes: [ { id: 1, @@ -72,6 +84,7 @@ describe.skip("confirmHandler", () => { location: "integrations:daily", attendees: [attendeeUser], responses: { name: attendeeUser.name, email: attendeeUser.email, guests: [] }, + userPrimaryEmail: organizer.email, }, ], organizer, @@ -98,5 +111,93 @@ describe.skip("confirmHandler", () => { }); expect(res?.status).toBe(BookingStatus.ACCEPTED); + expectWorkflowToBeTriggered({ emailsToReceive: [organizer.email], emails }); + }); + + test("should trigger BOOKING_REJECTED workflow when booking is rejected", async ({ emails }) => { + const attendeeUser = getOrganizer({ + email: "test@example.com", + name: "test name", + id: 102, + schedules: [TestData.schedules.IstWorkHours], + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + }); + + const uidOfBooking = "n5Wv3eHgconAED2j4gcVhP"; + const iCalUID = `${uidOfBooking}@Cal.com`; + + const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); + + await createBookingScenario( + getScenarioData({ + workflows: [ + { + userId: organizer.id, + trigger: "NEW_EVENT", + action: "EMAIL_HOST", + template: "REMINDER", + activeOn: [1], + }, + ], + eventTypes: [ + { + id: 1, + slotInterval: 15, + length: 15, + locations: [], + users: [ + { + id: 101, + }, + ], + }, + ], + bookings: [ + { + id: 101, + uid: uidOfBooking, + eventTypeId: 1, + status: BookingStatus.PENDING, + startTime: `${plus1DateString}T05:00:00.000Z`, + endTime: `${plus1DateString}T05:15:00.000Z`, + references: [], + iCalUID, + location: "integrations:daily", + attendees: [attendeeUser], + responses: { name: attendeeUser.name, email: attendeeUser.email, guests: [] }, + userPrimaryEmail: organizer.email, + }, + ], + organizer, + apps: [TestData.apps["daily-video"]], + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + }); + + const ctx = { + user: { + id: organizer.id, + name: organizer.name, + timeZone: organizer.timeZone, + username: organizer.username, + } as NonNullable, + }; + + const res = await confirmHandler({ + ctx, + input: { bookingId: 101, confirmed: false, reason: "Testing rejection" }, + }); + + expect(res?.status).toBe(BookingStatus.REJECTED); + expectWorkflowToBeTriggered({ emailsToReceive: [organizer.email], emails }); }); }); diff --git a/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts b/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts index eb08f22e9e..f7b151fc4b 100644 --- a/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts @@ -18,15 +18,18 @@ import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent"; import { processPaymentRefund } from "@calcom/lib/payment/processPaymentRefund"; import { getUsersCredentialsIncludeServiceAccountKey } from "@calcom/lib/server/getUsersCredentials"; import { getTranslation } from "@calcom/lib/server/i18n"; +import { WorkflowService } from "@calcom/lib/server/service/workflows"; import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat"; import { prisma } from "@calcom/prisma"; import { BookingStatus, MembershipRole, WebhookTriggerEvents, + WorkflowTriggerEvents, UserPermissionRole, } from "@calcom/prisma/enums"; import type { EventTypeMetadata } from "@calcom/prisma/zod-utils"; +import { getAllWorkflowsFromEventType } from "@calcom/trpc/server/routers/viewer/workflows/util"; import type { CalendarEvent } from "@calcom/types/Calendar"; import { TRPCError } from "@trpc/server"; @@ -378,6 +381,30 @@ export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => { smsReminderNumber: booking.smsReminderNumber || undefined, }; await handleWebhookTrigger({ subscriberOptions, eventTrigger, webhookData }); + + const workflows = await getAllWorkflowsFromEventType(booking.eventType, user.id); + try { + await WorkflowService.scheduleWorkflowsFilteredByTriggerEvent({ + workflows, + smsReminderNumber: booking.smsReminderNumber, + calendarEvent: { + ...evt, + bookerUrl: bookerUrl, + eventType: { + ...eventTypeInfo, + slug: booking.eventType?.slug as string, + }, + }, + hideBranding: !!booking.eventType?.owner?.hideBranding, + triggers: [WorkflowTriggerEvents.BOOKING_REJECTED], + }); + } catch (error) { + // Silently fail + console.error( + "Error while scheduling workflow reminders for BOOKING_REJECTED:", + error instanceof Error ? error.message : String(error) + ); + } } const message = `Booking ${confirmed}` ? "confirmed" : "rejected"; diff --git a/packages/trpc/server/routers/viewer/payments.tsx b/packages/trpc/server/routers/viewer/payments.tsx index aec5c75221..d14e6fdc63 100644 --- a/packages/trpc/server/routers/viewer/payments.tsx +++ b/packages/trpc/server/routers/viewer/payments.tsx @@ -2,12 +2,16 @@ import { z } from "zod"; import { PaymentServiceMap } from "@calcom/app-store/payment.services.generated"; import dayjs from "@calcom/dayjs"; +import { workflowSelect } from "@calcom/ee/workflows/lib/getAllWorkflows"; import { sendNoShowFeeChargedEmail } from "@calcom/emails"; import { WebhookService } from "@calcom/features/webhooks/lib/WebhookService"; +import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server"; import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId"; import { getTranslation } from "@calcom/lib/server/i18n"; -import { WebhookTriggerEvents } from "@calcom/prisma/enums"; +import { WorkflowService } from "@calcom/lib/server/service/workflows"; +import { WebhookTriggerEvents, WorkflowTriggerEvents } from "@calcom/prisma/enums"; import type { EventTypeMetadata } from "@calcom/prisma/zod-utils"; +import { getAllWorkflowsFromEventType } from "@calcom/trpc/server/routers/viewer/workflows/util"; import type { CalendarEvent } from "@calcom/types/Calendar"; import { TRPCError } from "@trpc/server"; @@ -40,7 +44,40 @@ export const paymentsRouter = router({ }, }, attendees: true, - eventType: true, + eventType: { + select: { + schedulingType: true, + owner: { + select: { + hideBranding: true, + }, + }, + hosts: { + select: { + user: { + select: { + email: true, + destinationCalendar: { + select: { + primaryEmail: true, + }, + }, + }, + }, + }, + }, + customReplyToEmail: true, + slug: true, + metadata: true, + workflows: { + select: { + workflow: { + select: workflowSelect, + }, + }, + }, + }, + }, }, }); @@ -73,6 +110,10 @@ export const paymentsRouter = router({ const attendeesList = await Promise.all(attendeesListPromises); + const orgId = await getOrgIdFromMemberOrTeamId({ memberId: ctx.user.id }); + const workflows = await getAllWorkflowsFromEventType(booking.eventType, ctx.user.id); + const bookerUrl = await getBookerBaseUrl(orgId ?? null); + const evt: CalendarEvent = { type: booking?.eventType?.slug as string, title: booking.title, @@ -91,6 +132,7 @@ export const paymentsRouter = router({ paymentOption: payment.paymentOption, }, customReplyToEmail: booking.eventType?.customReplyToEmail, + bookerUrl, }; const paymentCredential = await prisma.credential.findFirst({ @@ -129,7 +171,6 @@ export const paymentsRouter = router({ } const userId = ctx.user.id || 0; - const orgId = await getOrgIdFromMemberOrTeamId({ memberId: userId }); const eventTypeId = booking.eventTypeId || 0; const webhooks = await WebhookService.init({ userId, @@ -151,6 +192,31 @@ export const paymentsRouter = router({ booking?.eventType?.metadata as EventTypeMetadata ); + if (workflows.length > 0) { + try { + await WorkflowService.scheduleWorkflowsFilteredByTriggerEvent({ + workflows, + smsReminderNumber: booking.smsReminderNumber, + calendarEvent: { + ...evt, + bookerUrl, + eventType: { + ...booking.eventType, + slug: booking.eventType?.slug || "", + }, + }, + hideBranding: !!booking.eventType?.owner?.hideBranding, + triggers: [WorkflowTriggerEvents.BOOKING_PAID], + }); + } catch (error) { + // Silently fail + console.error( + "Error while scheduling workflow reminders for BOOKING_PAID:", + error instanceof Error ? error.message : String(error) + ); + } + } + return paymentData; } catch (err) { throw new TRPCError({