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 <samit91848@gmail.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 <samit91848@gmail.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 <samit91848@gmail.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 <samit91848@gmail.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 <samit91848@gmail.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 <samit91848@gmail.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 <samit91848@gmail.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>
This commit is contained in:
Amit Sharma
2025-09-04 16:55:09 +05:30
committed by GitHub
co-authored by amit@cal.com coderabbitai[bot] Devin AI Carina Wollendorfer
parent b052c2e029
commit 3bbba9c6cb
27 changed files with 1007 additions and 85 deletions
@@ -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;
@@ -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;
@@ -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",
+101 -1
View File
@@ -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"
}
]
},
+20 -2
View File
@@ -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,
};
}
+16 -4
View File
@@ -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);
});
});
@@ -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?",
@@ -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));
@@ -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 () => {
@@ -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
@@ -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 }));
@@ -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
);
@@ -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 }));
@@ -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,
];
@@ -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?
@@ -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,
@@ -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: {
@@ -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;
}
@@ -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: {
+195 -2
View File
@@ -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);
+19
View File
@@ -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,
+65
View File
@@ -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)),
});
}
}
@@ -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';
+5
View File
@@ -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 {
@@ -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<TrpcSessionUser>,
};
const res = await confirmHandler({
ctx,
input: { bookingId: 101, confirmed: false, reason: "Testing rejection" },
});
expect(res?.status).toBe(BookingStatus.REJECTED);
expectWorkflowToBeTriggered({ emailsToReceive: [organizer.email], emails });
});
});
@@ -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";
@@ -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({