diff --git a/apps/api/v2/src/modules/organizations/teams/workflows/controllers/org-team-workflows.controller.e2e-spec.ts b/apps/api/v2/src/modules/organizations/teams/workflows/controllers/org-team-workflows.controller.e2e-spec.ts index d99630aaa6..89744b284f 100644 --- a/apps/api/v2/src/modules/organizations/teams/workflows/controllers/org-team-workflows.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/organizations/teams/workflows/controllers/org-team-workflows.controller.e2e-spec.ts @@ -27,8 +27,10 @@ import { BEFORE_EVENT, DAY, FORM_SUBMITTED, + FORM_SUBMITTED_NO_EVENT, OnAfterEventTriggerDto, OnBeforeEventTriggerDto, + OnFormSubmittedNoEventTriggerDto, OnFormSubmittedTriggerDto, } from "@/modules/workflows/inputs/workflow-trigger.input"; import { @@ -471,6 +473,64 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => { }); }); + it("should create a new routing form workflow with allowed actions and offset trigger", async () => { + const validWorkflow = structuredClone( + sampleCreateWorkflowRoutingFormDto + ) as unknown as CreateFormWorkflowDto; + validWorkflow.steps = [ + { + stepNumber: 1, + action: "email_attendee", + recipient: ATTENDEE, + template: REMINDER, + sender: "CalcomE2EStep1", + includeCalendarEvent: true, + message: { + subject: "Upcoming: {EVENT_NAME}", + html: "

Reminder for your event {EVENT_NAME}.

", + }, + }, + ]; + + validWorkflow.trigger = { + type: FORM_SUBMITTED_NO_EVENT, + offset: { + value: 1, + unit: DAY, + }, + }; + return request(app.getHttpServer()) + .post(`${basePath}/routing-form`) + .set({ Authorization: `Bearer cal_test_${apiKeyString}` }) + .send(validWorkflow) + .expect(201) + .then((response) => { + const responseBody: GetRoutingFormWorkflowOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.name).toEqual(sampleCreateWorkflowRoutingFormDto.name); + expect(responseBody.data.type).toEqual("routing-form"); + + if (responseBody.data.activation instanceof WorkflowFormActivationDto) { + expect(responseBody.data.activation.isActiveOnAllRoutingForms).toEqual( + sampleCreateWorkflowRoutingFormDto.activation.isActiveOnAllRoutingForms + ); + } + + expect(responseBody.data.trigger.type).toEqual(validWorkflow.trigger.type); + expect(responseBody.data.steps).toHaveLength(sampleCreateWorkflowRoutingFormDto.steps.length); + expect(responseBody.data.steps.find((step) => step.stepNumber === 1)?.id).toBeDefined(); + expect(responseBody.data.steps.find((step) => step.stepNumber === 1)?.sender).toEqual( + "CalcomE2EStep1" + ); + + const trigger = validWorkflow.trigger as OnFormSubmittedNoEventTriggerDto; + expect(responseBody.data.trigger?.type).toEqual(trigger.type); + expect(responseBody.data.trigger?.offset?.unit).toEqual(trigger.offset.unit); + expect(responseBody.data.trigger?.offset?.value).toEqual(trigger.offset.value); + }); + }); + it("should create a new workflow", async () => { return request(app.getHttpServer()) .post(basePath) diff --git a/apps/api/v2/src/modules/workflows/inputs/create-form-workflow.ts b/apps/api/v2/src/modules/workflows/inputs/create-form-workflow.ts index 0a40075373..7ec8ee909c 100644 --- a/apps/api/v2/src/modules/workflows/inputs/create-form-workflow.ts +++ b/apps/api/v2/src/modules/workflows/inputs/create-form-workflow.ts @@ -13,7 +13,9 @@ import { import { RoutingFormWorkflowTriggerDto, FORM_SUBMITTED, + FORM_SUBMITTED_NO_EVENT, FORM_WORKFLOW_TRIGGER_TYPES, + OnFormSubmittedNoEventTriggerDto, OnFormSubmittedTriggerDto, } from "./workflow-trigger.input"; @@ -39,6 +41,7 @@ export class WorkflowFormActivationDto { @ApiExtraModels( OnFormSubmittedTriggerDto, + OnFormSubmittedNoEventTriggerDto, WorkflowEmailAddressStepDto, WorkflowEmailAttendeeStepDto, RoutingFormWorkflowTriggerDto, @@ -59,17 +62,23 @@ export class CreateFormWorkflowDto { @ApiProperty({ description: `Trigger configuration for the routing-form workflow, allowed triggers are ${FORM_WORKFLOW_TRIGGER_TYPES.toString()}`, - oneOf: [{ $ref: getSchemaPath(OnFormSubmittedTriggerDto) }], + oneOf: [ + { $ref: getSchemaPath(OnFormSubmittedTriggerDto) }, + { $ref: getSchemaPath(OnFormSubmittedNoEventTriggerDto) }, + ], }) @ValidateNested() @Type(() => RoutingFormWorkflowTriggerDto, { keepDiscriminatorProperty: true, discriminator: { property: "type", - subTypes: [{ value: OnFormSubmittedTriggerDto, name: FORM_SUBMITTED }], + subTypes: [ + { value: OnFormSubmittedTriggerDto, name: FORM_SUBMITTED }, + { value: OnFormSubmittedNoEventTriggerDto, name: FORM_SUBMITTED_NO_EVENT }, + ], }, }) - trigger!: OnFormSubmittedTriggerDto; + trigger!: OnFormSubmittedTriggerDto | OnFormSubmittedNoEventTriggerDto; @ApiProperty({ description: `Steps to execute as part of the routing-form workflow, allowed steps are ${FORM_ALLOWED_STEP_ACTIONS.toString()}`, diff --git a/apps/api/v2/src/modules/workflows/inputs/update-form-workflow.input.ts b/apps/api/v2/src/modules/workflows/inputs/update-form-workflow.input.ts index f6742f3c5e..d25295917e 100644 --- a/apps/api/v2/src/modules/workflows/inputs/update-form-workflow.input.ts +++ b/apps/api/v2/src/modules/workflows/inputs/update-form-workflow.input.ts @@ -18,13 +18,16 @@ import { } from "./workflow-step.input"; import { OnFormSubmittedTriggerDto, + OnFormSubmittedNoEventTriggerDto, FORM_SUBMITTED, + FORM_SUBMITTED_NO_EVENT, FORM_WORKFLOW_TRIGGER_TYPES, RoutingFormWorkflowTriggerDto, } from "./workflow-trigger.input"; @ApiExtraModels( OnFormSubmittedTriggerDto, + OnFormSubmittedNoEventTriggerDto, UpdateEmailAddressWorkflowStepDto, UpdateEmailAttendeeWorkflowStepDto, UpdateEmailHostWorkflowStepDto, @@ -48,7 +51,10 @@ export class UpdateFormWorkflowDto { @ApiPropertyOptional({ description: `Trigger configuration for the routing-form workflow, allowed triggers are ${FORM_WORKFLOW_TRIGGER_TYPES}`, - oneOf: [{ $ref: getSchemaPath(OnFormSubmittedTriggerDto) }], + oneOf: [ + { $ref: getSchemaPath(OnFormSubmittedTriggerDto) }, + { $ref: getSchemaPath(OnFormSubmittedNoEventTriggerDto) }, + ], }) @IsOptional() @ValidateNested() @@ -56,10 +62,13 @@ export class UpdateFormWorkflowDto { keepDiscriminatorProperty: true, discriminator: { property: "type", - subTypes: [{ value: OnFormSubmittedTriggerDto, name: FORM_SUBMITTED }], + subTypes: [ + { value: OnFormSubmittedTriggerDto, name: FORM_SUBMITTED }, + { value: OnFormSubmittedNoEventTriggerDto, name: FORM_SUBMITTED_NO_EVENT }, + ], }, }) - trigger?: OnFormSubmittedTriggerDto; + trigger?: OnFormSubmittedTriggerDto | OnFormSubmittedNoEventTriggerDto; @ApiPropertyOptional({ description: `Steps to execute as part of the routing-form workflow, allowed steps are ${FORM_ALLOWED_STEP_ACTIONS.toString()}`, 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 6789820fbb..5c1b970641 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 @@ -12,13 +12,14 @@ 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 FORM_SUBMITTED = "formSubmitted"; +export const FORM_SUBMITTED_NO_EVENT = "formSubmittedNoEvent"; 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 FORM_WORKFLOW_TRIGGER_TYPES = [FORM_SUBMITTED] as const; +export const FORM_WORKFLOW_TRIGGER_TYPES = [FORM_SUBMITTED, FORM_SUBMITTED_NO_EVENT] as const; export const EVENT_TYPE_WORKFLOW_TRIGGER_TYPES = [ BEFORE_EVENT, @@ -44,6 +45,7 @@ export const WORKFLOW_TRIGGER_TYPES = [ AFTER_HOSTS_CAL_VIDEO_NO_SHOW, AFTER_GUESTS_CAL_VIDEO_NO_SHOW, FORM_SUBMITTED, + FORM_SUBMITTED_NO_EVENT, BOOKING_REJECTED, BOOKING_REQUESTED, BOOKING_PAYMENT_INITIATED, @@ -60,6 +62,7 @@ export const WORKFLOW_TRIGGER_TO_ENUM = { [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, [FORM_SUBMITTED]: WorkflowTriggerEvents.FORM_SUBMITTED, + [FORM_SUBMITTED_NO_EVENT]: WorkflowTriggerEvents.FORM_SUBMITTED_NO_EVENT, [BOOKING_REJECTED]: WorkflowTriggerEvents.BOOKING_REJECTED, [BOOKING_REQUESTED]: WorkflowTriggerEvents.BOOKING_REQUESTED, [BOOKING_PAYMENT_INITIATED]: WorkflowTriggerEvents.BOOKING_PAYMENT_INITIATED, @@ -67,6 +70,19 @@ export const WORKFLOW_TRIGGER_TO_ENUM = { [BOOKING_PAID]: WorkflowTriggerEvents.BOOKING_PAID, } as const; +export const ENUM_ROUTING_FORM_WORFLOW_TRIGGERS = [ + WORKFLOW_TRIGGER_TO_ENUM[FORM_SUBMITTED_NO_EVENT], + WORKFLOW_TRIGGER_TO_ENUM[FORM_SUBMITTED], +]; + +export const ENUM_OFFSET_WORFLOW_TRIGGERS = [ + WORKFLOW_TRIGGER_TO_ENUM[FORM_SUBMITTED_NO_EVENT], + WORKFLOW_TRIGGER_TO_ENUM[BEFORE_EVENT], + WORKFLOW_TRIGGER_TO_ENUM[AFTER_EVENT], + WORKFLOW_TRIGGER_TO_ENUM[AFTER_GUESTS_CAL_VIDEO_NO_SHOW], + WORKFLOW_TRIGGER_TO_ENUM[AFTER_HOSTS_CAL_VIDEO_NO_SHOW], +]; + export const ENUM_TO_WORKFLOW_TRIGGER = { [WorkflowTriggerEvents.BEFORE_EVENT]: BEFORE_EVENT, [WorkflowTriggerEvents.EVENT_CANCELLED]: EVENT_CANCELLED, @@ -76,6 +92,7 @@ export const ENUM_TO_WORKFLOW_TRIGGER = { [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.FORM_SUBMITTED]: FORM_SUBMITTED, + [WorkflowTriggerEvents.FORM_SUBMITTED_NO_EVENT]: FORM_SUBMITTED_NO_EVENT, [WorkflowTriggerEvents.BOOKING_REJECTED]: BOOKING_REJECTED, [WorkflowTriggerEvents.BOOKING_REQUESTED]: BOOKING_REQUESTED, [WorkflowTriggerEvents.BOOKING_PAYMENT_INITIATED]: BOOKING_PAYMENT_INITIATED, @@ -205,7 +222,8 @@ export class OnNoShowUpdateTriggerDto { export class TriggerOffsetDTO { @ApiProperty({ - description: "Offset before/after the trigger time; required for BEFORE_EVENT and AFTER_EVENT only", + description: + "Offset before/after the trigger time; required for BEFORE_EVENT, AFTER_EVENT, and FORM_SUBMITTED_NO_EVENT", type: WorkflowTriggerOffsetDto, }) @ValidateNested() @@ -261,3 +279,22 @@ export class OnFormSubmittedTriggerDto { @IsIn([FORM_SUBMITTED]) type: typeof FORM_SUBMITTED = FORM_SUBMITTED; } + +export class OnFormSubmittedNoEventTriggerDto extends TriggerOffsetDTO { + @ApiProperty({ + description: "Trigger type for the workflow", + example: FORM_SUBMITTED_NO_EVENT, + }) + @IsString() + @IsIn([FORM_SUBMITTED_NO_EVENT]) + type: typeof FORM_SUBMITTED_NO_EVENT = FORM_SUBMITTED_NO_EVENT; +} + +export const OffsetTriggerDTOInstances = [ + OnFormSubmittedNoEventTriggerDto, + OnBeforeEventTriggerDto, + OnAfterEventTriggerDto, + OnAfterCalVideoGuestsNoShowTriggerDto, + OnAfterEventTriggerDto, +]; +export type OffsetTriggerDTOInstancesType = InstanceType<(typeof OffsetTriggerDTOInstances)[number]>; diff --git a/apps/api/v2/src/modules/workflows/outputs/routing-form-workflow.output.ts b/apps/api/v2/src/modules/workflows/outputs/routing-form-workflow.output.ts index d5987cf88f..d44184ae05 100644 --- a/apps/api/v2/src/modules/workflows/outputs/routing-form-workflow.output.ts +++ b/apps/api/v2/src/modules/workflows/outputs/routing-form-workflow.output.ts @@ -35,7 +35,7 @@ export class RoutingFormWorkflowTriggerOutputDto { type!: WorkflowFormTriggerType; @ApiPropertyOptional({ - description: "Offset details (present for BEFORE_EVENT/AFTER_EVENT)", + description: "Offset details (present for BEFORE_EVENT/AFTER_EVENT/FORM_SUBMITTED_NO_EVENT)", type: WorkflowTriggerOffsetOutputDto, }) @Expose() diff --git a/apps/api/v2/src/modules/workflows/services/workflows.input.service.ts b/apps/api/v2/src/modules/workflows/services/workflows.input.service.ts index d3e2154eaf..3d67bd0699 100644 --- a/apps/api/v2/src/modules/workflows/services/workflows.input.service.ts +++ b/apps/api/v2/src/modules/workflows/services/workflows.input.service.ts @@ -21,8 +21,8 @@ import { WHATSAPP_NUMBER, } from "../inputs/workflow-step.input"; import { - OnAfterEventTriggerDto, - OnBeforeEventTriggerDto, + OffsetTriggerDTOInstances, + OffsetTriggerDTOInstancesType, TIME_UNIT_TO_ENUM, WORKFLOW_TRIGGER_TO_ENUM, } from "../inputs/workflow-trigger.input"; @@ -31,6 +31,12 @@ import { export class WorkflowsInputService { constructor(private readonly teamsVerifiedResourcesRepository: TeamsVerifiedResourcesRepository) {} + private _isOffsetTrigger( + trigger: UpdateEventTypeWorkflowDto["trigger"] | UpdateFormWorkflowDto["trigger"] + ): trigger is OffsetTriggerDTOInstancesType { + return OffsetTriggerDTOInstances.some((Instance) => trigger instanceof Instance); + } + private async _getTeamPhoneNumberFromVerifiedId(teamId: number, verifiedPhoneId: number) { const phoneResource = await this.teamsVerifiedResourcesRepository.getTeamVerifiedPhoneNumberById( verifiedPhoneId, @@ -135,17 +141,13 @@ export class WorkflowsInputService { ? WORKFLOW_TRIGGER_TO_ENUM[updateDto?.trigger?.type] : currentData.trigger; - const timeUnitForZod = - updateDto.trigger instanceof OnBeforeEventTriggerDto || - updateDto.trigger instanceof OnAfterEventTriggerDto - ? updateDto?.trigger?.offset?.unit ?? currentData.timeUnit ?? null - : undefined; + const timeUnitForZod = this._isOffsetTrigger(updateDto.trigger) + ? updateDto?.trigger?.offset?.unit ?? currentData.timeUnit ?? null + : undefined; - const time = - updateDto.trigger instanceof OnBeforeEventTriggerDto || - updateDto.trigger instanceof OnAfterEventTriggerDto - ? updateDto?.trigger?.offset?.value ?? currentData?.time ?? null - : null; + const time = this._isOffsetTrigger(updateDto.trigger) + ? updateDto?.trigger?.offset?.value ?? currentData?.time ?? null + : null; const timeUnit = timeUnitForZod ? TIME_UNIT_TO_ENUM[timeUnitForZod] : null; diff --git a/apps/api/v2/src/modules/workflows/services/workflows.output.service.ts b/apps/api/v2/src/modules/workflows/services/workflows.output.service.ts index 3bf96d06aa..f810662a8b 100644 --- a/apps/api/v2/src/modules/workflows/services/workflows.output.service.ts +++ b/apps/api/v2/src/modules/workflows/services/workflows.output.service.ts @@ -32,13 +32,8 @@ import { WHATSAPP_NUMBER, } from "../inputs/workflow-step.input"; import { - AFTER_EVENT, - AFTER_GUESTS_CAL_VIDEO_NO_SHOW, - AFTER_HOSTS_CAL_VIDEO_NO_SHOW, - BEFORE_EVENT, ENUM_TO_TIME_UNIT, ENUM_TO_WORKFLOW_TRIGGER, - FORM_SUBMITTED, HOUR, OnAfterCalVideoGuestsNoShowTriggerDto, OnAfterCalVideoHostsNoShowTriggerDto, @@ -47,6 +42,7 @@ import { OnCancelTriggerDto, OnCreationTriggerDto, OnFormSubmittedTriggerDto, + OnFormSubmittedNoEventTriggerDto, OnNoShowUpdateTriggerDto, OnPaidTriggerDto, OnPaymentInitiatedTriggerDto, @@ -54,6 +50,9 @@ import { OnRequestedTriggerDto, OnRescheduleTriggerDto, WORKFLOW_TRIGGER_TO_ENUM, + FORM_WORKFLOW_TRIGGER_TYPES, + ENUM_ROUTING_FORM_WORFLOW_TRIGGERS, + ENUM_OFFSET_WORFLOW_TRIGGERS, } from "../inputs/workflow-trigger.input"; export type TriggerDtoType = @@ -64,6 +63,7 @@ export type TriggerDtoType = | OnCancelTriggerDto | OnAfterCalVideoGuestsNoShowTriggerDto | OnFormSubmittedTriggerDto + | OnFormSubmittedNoEventTriggerDto | OnRejectedTriggerDto | OnRequestedTriggerDto | OnPaymentInitiatedTriggerDto @@ -141,10 +141,20 @@ const ACTION_CONFIG_MAP = { @Injectable() export class WorkflowsOutputService { _isFormAllowedStepAction(action: StepAction): action is FormAllowedStepAction { - if (FORM_ALLOWED_STEP_ACTIONS.some((formAction) => formAction === action)) { - return true; - } - return false; + return FORM_ALLOWED_STEP_ACTIONS.some((formAction) => formAction === action); + } + _isFormAllowedTrigger( + trigger: WorkflowType["trigger"] + ): trigger is (typeof ENUM_ROUTING_FORM_WORFLOW_TRIGGERS)[number] { + return FORM_WORKFLOW_TRIGGER_TYPES.some( + (formTrigger) => WORKFLOW_TRIGGER_TO_ENUM[formTrigger] === trigger + ); + } + + private _isOffsetTrigger( + trigger: WorkflowType["trigger"] + ): trigger is (typeof ENUM_OFFSET_WORFLOW_TRIGGERS)[number] { + return ENUM_OFFSET_WORFLOW_TRIGGERS.some((offsetTrigger) => offsetTrigger === trigger); } /** @@ -200,14 +210,22 @@ export class WorkflowsOutputService { } toRoutingFormOutputDto(workflow: WorkflowType): RoutingFormWorkflowOutput | void { - if (workflow.type === "ROUTING_FORM" && workflow.trigger === WORKFLOW_TRIGGER_TO_ENUM[FORM_SUBMITTED]) { + if (workflow.type === "ROUTING_FORM" && this._isFormAllowedTrigger(workflow.trigger)) { const activation: WorkflowFormActivationDto = { isActiveOnAllRoutingForms: workflow.isActiveOnAll, activeOnRoutingFormIds: workflow.activeOnRoutingForms?.map((relation) => relation.routingFormId) ?? [], }; - const trigger: TriggerDtoType = { type: ENUM_TO_WORKFLOW_TRIGGER[workflow.trigger] }; + const trigger: TriggerDtoType = this._isOffsetTrigger(workflow.trigger) + ? { + type: ENUM_TO_WORKFLOW_TRIGGER[workflow.trigger], + offset: { + value: workflow.time ?? 1, + unit: workflow.timeUnit ? ENUM_TO_TIME_UNIT[workflow.timeUnit] : HOUR, + }, + } + : { type: ENUM_TO_WORKFLOW_TRIGGER[workflow.trigger] }; const steps: RoutingFormWorkflowStepOutputDto[] = workflow.steps.map((step) => { return this.mapStep(step, "routing-form"); @@ -225,25 +243,21 @@ export class WorkflowsOutputService { } toEventTypeOutputDto(workflow: WorkflowType): EventTypeWorkflowOutput | void { - if (workflow.type === "EVENT_TYPE" && workflow.trigger !== WORKFLOW_TRIGGER_TO_ENUM[FORM_SUBMITTED]) { + if (workflow.type === "EVENT_TYPE" && !this._isFormAllowedTrigger(workflow.trigger)) { const activation: WorkflowActivationDto = { isActiveOnAllEventTypes: workflow.isActiveOnAll, activeOnEventTypeIds: workflow.activeOn?.map((relation) => relation.eventTypeId) ?? [], }; - const trigger: TriggerEventTypeDtoType = - workflow.trigger === WORKFLOW_TRIGGER_TO_ENUM[BEFORE_EVENT] || - workflow.trigger === WORKFLOW_TRIGGER_TO_ENUM[AFTER_EVENT] || - workflow.trigger === WORKFLOW_TRIGGER_TO_ENUM[AFTER_GUESTS_CAL_VIDEO_NO_SHOW] || - workflow.trigger === WORKFLOW_TRIGGER_TO_ENUM[AFTER_HOSTS_CAL_VIDEO_NO_SHOW] - ? { - type: ENUM_TO_WORKFLOW_TRIGGER[workflow.trigger], - offset: { - value: workflow.time ?? 1, - unit: workflow.timeUnit ? ENUM_TO_TIME_UNIT[workflow.timeUnit] : HOUR, - }, - } - : { type: ENUM_TO_WORKFLOW_TRIGGER[workflow.trigger] }; + const trigger: TriggerEventTypeDtoType = this._isOffsetTrigger(workflow.trigger) + ? { + type: ENUM_TO_WORKFLOW_TRIGGER[workflow.trigger], + offset: { + value: workflow.time ?? 1, + unit: workflow.timeUnit ? ENUM_TO_TIME_UNIT[workflow.timeUnit] : HOUR, + }, + } + : { type: ENUM_TO_WORKFLOW_TRIGGER[workflow.trigger] }; const steps: EventTypeWorkflowStepOutputDto[] = workflow.steps.map((step) => { return this.mapStep(step, "event-type"); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index ce2c3c4f43..4f1e6f4508 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -3717,6 +3717,8 @@ "stats": "Stats", "booking_status": "Booking status", "form_submitted_trigger": "When routing form is submitted", + "form_submitted_no_event_trigger": "When routing form is submitted and no booking is created", + "how_long_after_form_submitted_no_event": "How long after the form was submitted?", "visit": "Visit", "location_custom_label_input_label": "Custom label on booking page", "meeting_link": "Meeting link", diff --git a/packages/app-store/routing-forms/lib/formSubmissionUtils.test.ts b/packages/app-store/routing-forms/lib/formSubmissionUtils.test.ts index 8ff6469e32..03adbcd2a8 100644 --- a/packages/app-store/routing-forms/lib/formSubmissionUtils.test.ts +++ b/packages/app-store/routing-forms/lib/formSubmissionUtils.test.ts @@ -141,6 +141,59 @@ describe("_onFormSubmission", () => { }, name: { value: "Test Name", response: "Test Name" }, }, + responseId, + form: { + ...mockForm, + fields: mockForm.fields.map((field) => ({ + type: field.type, + identifier: field.identifier, + })), + }, + }); + }); + + it("should call WorkflowService.scheduleFormWorkflows for FORM_SUBMITTED_NO_EVENT workflows", async () => { + const mockWorkflows = [ + { + id: 2, + name: "Form Follow-up Workflow", + userId: 1, + teamId: null, + trigger: WorkflowTriggerEvents.FORM_SUBMITTED_NO_EVENT, + time: 30, + timeUnit: "MINUTE", + steps: [ + { + id: 2, + action: WorkflowActions.EMAIL_ATTENDEE, + sendTo: null, + reminderBody: "Follow up on your form submission", + emailSubject: "Follow Up", + template: WorkflowTemplates.CUSTOM, + verifiedAt: new Date(), + includeCalendarEvent: false, + numberVerificationPending: false, + numberRequired: false, + }, + ], + }, + ]; + + vi.mocked(WorkflowService.getAllWorkflowsFromRoutingForm).mockResolvedValueOnce(mockWorkflows as any); + + await _onFormSubmission(mockForm as any, mockResponse, responseId); + + expect(WorkflowService.getAllWorkflowsFromRoutingForm).toHaveBeenCalledWith(mockForm); + expect(WorkflowService.scheduleFormWorkflows).toHaveBeenCalledWith({ + workflows: mockWorkflows, + responses: { + email: { + value: "test@response.com", + response: "test@response.com", + }, + name: { value: "Test Name", response: "Test Name" }, + }, + responseId, form: { ...mockForm, fields: mockForm.fields.map((field) => ({ diff --git a/packages/app-store/routing-forms/lib/formSubmissionUtils.ts b/packages/app-store/routing-forms/lib/formSubmissionUtils.ts index 4147942d0f..8c24ece10c 100644 --- a/packages/app-store/routing-forms/lib/formSubmissionUtils.ts +++ b/packages/app-store/routing-forms/lib/formSubmissionUtils.ts @@ -222,6 +222,7 @@ export async function _onFormSubmission( await WorkflowService.scheduleFormWorkflows({ workflows, + responseId, responses: fieldResponsesByIdentifier, form: { ...form, diff --git a/packages/features/bookings/repositories/BookingRepository.ts b/packages/features/bookings/repositories/BookingRepository.ts index 4aaf5c641b..d7c1643511 100644 --- a/packages/features/bookings/repositories/BookingRepository.ts +++ b/packages/features/bookings/repositories/BookingRepository.ts @@ -386,6 +386,21 @@ export class BookingRepository { }); } + async findFirstBookingFromResponse({ responseId }: { responseId: number }) { + const booking = await this.prismaClient.booking.findFirst({ + where: { + routedFromRoutingFormReponse: { + id: responseId, + }, + }, + select: { + id: true, + }, + }); + + return booking; + } + async findBookingByUidWithEventType({ bookingUid }: { bookingUid: string }) { return await this.prismaClient.booking.findUnique({ where: { diff --git a/packages/features/ee/workflows/components/WorkflowStepContainer.tsx b/packages/features/ee/workflows/components/WorkflowStepContainer.tsx index ee47cc644b..04620593c9 100644 --- a/packages/features/ee/workflows/components/WorkflowStepContainer.tsx +++ b/packages/features/ee/workflows/components/WorkflowStepContainer.tsx @@ -114,6 +114,7 @@ const getTimeSectionText = (trigger: WorkflowTriggerEvents, t: TFunction) => { [WorkflowTriggerEvents.BEFORE_EVENT]: "how_long_before", [WorkflowTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW]: "how_long_after_hosts_no_show", [WorkflowTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW]: "how_long_after_guests_no_show", + [WorkflowTriggerEvents.FORM_SUBMITTED_NO_EVENT]: "how_long_after_form_submitted_no_event", }; return triggerMap[trigger] ? t(triggerMap[trigger]) : null; }; diff --git a/packages/features/ee/workflows/lib/constants.ts b/packages/features/ee/workflows/lib/constants.ts index 4ebff5b077..a9703887b0 100644 --- a/packages/features/ee/workflows/lib/constants.ts +++ b/packages/features/ee/workflows/lib/constants.ts @@ -9,6 +9,7 @@ export const WORKFLOW_TRIGGER_EVENTS = [ WorkflowTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW, WorkflowTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW, WorkflowTriggerEvents.FORM_SUBMITTED, + WorkflowTriggerEvents.FORM_SUBMITTED_NO_EVENT, WorkflowTriggerEvents.BOOKING_REJECTED, WorkflowTriggerEvents.BOOKING_REQUESTED, WorkflowTriggerEvents.BOOKING_PAYMENT_INITIATED, @@ -96,9 +97,13 @@ export const IMMEDIATE_WORKFLOW_TRIGGER_EVENTS: WorkflowTriggerEvents[] = [ WorkflowTriggerEvents.BOOKING_REJECTED, WorkflowTriggerEvents.BOOKING_REQUESTED, WorkflowTriggerEvents.FORM_SUBMITTED, + WorkflowTriggerEvents.FORM_SUBMITTED_NO_EVENT, // no real immediate workflow but it's scheduled with tasker ]; -export const FORM_TRIGGER_WORKFLOW_EVENTS: WorkflowTriggerEvents[] = [WorkflowTriggerEvents.FORM_SUBMITTED]; +export const FORM_TRIGGER_WORKFLOW_EVENTS: WorkflowTriggerEvents[] = [ + WorkflowTriggerEvents.FORM_SUBMITTED, + WorkflowTriggerEvents.FORM_SUBMITTED_NO_EVENT, +]; export const ALLOWED_FORM_WORKFLOW_ACTIONS = [ WorkflowActions.EMAIL_ATTENDEE, diff --git a/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts b/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts index 0762cb64eb..d1f2609127 100644 --- a/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts +++ b/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts @@ -9,7 +9,7 @@ import { import { sendOrScheduleWorkflowEmails } from "@calcom/features/ee/workflows/lib/reminders/providers/emailProvider"; import * as twilio from "@calcom/features/ee/workflows/lib/reminders/providers/twilioProvider"; import type { Workflow, WorkflowStep } from "@calcom/features/ee/workflows/lib/types"; -import { getSubmitterEmail } from "@calcom/features/tasker/tasks/triggerFormSubmittedNoEvent/triggerFormSubmittedNoEventWebhook"; +import { getSubmitterEmail } from "@calcom/features/tasker/tasks/triggerFormSubmittedNoEvent/formSubmissionValidation"; import { UserRepository } from "@calcom/features/users/repositories/UserRepository"; import { checkSMSRateLimit } from "@calcom/lib/checkRateLimitAndThrowError"; import { SENDER_NAME } from "@calcom/lib/constants"; diff --git a/packages/features/tasker/tasker.ts b/packages/features/tasker/tasker.ts index 8d145eeacb..4808c17cfd 100644 --- a/packages/features/tasker/tasker.ts +++ b/packages/features/tasker/tasker.ts @@ -14,6 +14,9 @@ type TaskPayloads = { triggerFormSubmittedNoEventWebhook: z.infer< typeof import("./tasks/triggerFormSubmittedNoEvent/triggerFormSubmittedNoEventWebhook").ZTriggerFormSubmittedNoEventWebhookPayloadSchema >; + triggerFormSubmittedNoEventWorkflow: z.infer< + typeof import("./tasks/triggerFormSubmittedNoEvent/triggerFormSubmittedNoEventWorkflow").ZTriggerFormSubmittedNoEventWorkflowPayloadSchema + >; translateEventTypeData: z.infer< typeof import("./tasks/translateEventTypeData").ZTranslateEventDataPayloadSchema >; diff --git a/packages/features/tasker/tasks/index.ts b/packages/features/tasker/tasks/index.ts index 2bff98c396..640caa3dee 100644 --- a/packages/features/tasker/tasks/index.ts +++ b/packages/features/tasker/tasks/index.ts @@ -18,6 +18,10 @@ const tasks: Record Promise> = { import("./triggerFormSubmittedNoEvent/triggerFormSubmittedNoEventWebhook").then( (module) => module.triggerFormSubmittedNoEventWebhook ), + triggerFormSubmittedNoEventWorkflow: () => + import("./triggerFormSubmittedNoEvent/triggerFormSubmittedNoEventWorkflow").then( + (module) => module.triggerFormSubmittedNoEventWorkflow + ), sendSms: () => Promise.resolve(() => Promise.reject(new Error("Not implemented"))), translateEventTypeData: () => import("./translateEventTypeData").then((module) => module.translateEventTypeData), diff --git a/packages/features/tasker/tasks/triggerFormSubmittedNoEvent/formSubmissionValidation.ts b/packages/features/tasker/tasks/triggerFormSubmittedNoEvent/formSubmissionValidation.ts new file mode 100644 index 0000000000..82098271e4 --- /dev/null +++ b/packages/features/tasker/tasks/triggerFormSubmittedNoEvent/formSubmissionValidation.ts @@ -0,0 +1,96 @@ +import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; +import { RoutingFormResponseRepository } from "@calcom/lib/server/repository/formResponse"; +import prisma from "@calcom/prisma"; + +export interface ValidationOptions { + responseId: number; + formId: string; + responses: any; + submittedAt?: Date; +} + +export interface ValidationResult { + skip: boolean; + reason?: string; +} + +/** + * Check if trigger should be skipped due to booking creation or duplicate submission + */ +export async function shouldTriggerFormSubmittedNoEvent(options: ValidationOptions) { + const { formId, responseId, responses, submittedAt } = options; + + const bookingRepository = new BookingRepository(prisma); + + // Check if a booking was created from this form response + const bookingFromResponse = await bookingRepository.findFirstBookingFromResponse({ responseId }); + + if (bookingFromResponse) return false; + + // Check for duplicate form submissions + const hasDuplicate = await hasDuplicateSubmission({ formId, responseId, responses, submittedAt }); + if (hasDuplicate) { + return false; + } + + return true; +} + +export function getSubmitterEmail(responses: any) { + const submitterEmail = Object.values(responses).find( + (response): response is { value: string; label: string } => { + const value = + typeof response === "object" && response && "value" in response ? response.value : response; + return typeof value === "string" && value.includes("@"); + } + )?.value; + return submitterEmail; +} + +/** + * Check for duplicate form submissions within the last 60 minutes + */ +async function hasDuplicateSubmission({ + formId, + responses, + responseId, + submittedAt, +}: { + formId: string; + responses: any; + responseId: number; + submittedAt?: Date; +}): Promise { + const submitterEmail = getSubmitterEmail(responses); + + if (!submitterEmail) return false; + + const date = submittedAt ?? new Date(); + const formResponseRepository = new RoutingFormResponseRepository(prisma); + + const sixtyMinutesAgo = new Date(date.getTime() - 60 * 60 * 1000); + + const recentResponses = await formResponseRepository.findAllResponsesWithBooking({ + formId, + responseId, + createdAfter: sixtyMinutesAgo, + createdBefore: new Date(), + }); + + // Check if there's a duplicate email in recent responses + return recentResponses.some((response) => { + if (!response.response || typeof response.response !== "object") return false; + + return Object.values(response.response as Record).some( + (field) => { + return ( + typeof field === "object" && + field && + "value" in field && + typeof field.value === "string" && + field.value.toLowerCase() === submitterEmail.toLowerCase() + ); + } + ); + }); +} diff --git a/packages/features/tasker/tasks/triggerFormSubmittedNoEvent/triggerFormSubmittedNoEventWebhook.ts b/packages/features/tasker/tasks/triggerFormSubmittedNoEvent/triggerFormSubmittedNoEventWebhook.ts index a8b621e570..e41a34f762 100644 --- a/packages/features/tasker/tasks/triggerFormSubmittedNoEvent/triggerFormSubmittedNoEventWebhook.ts +++ b/packages/features/tasker/tasks/triggerFormSubmittedNoEvent/triggerFormSubmittedNoEventWebhook.ts @@ -5,6 +5,8 @@ import incompleteBookingActionFunctions from "@calcom/app-store/routing-forms/li import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload"; import prisma from "@calcom/prisma"; +import { getSubmitterEmail, shouldTriggerFormSubmittedNoEvent } from "./formSubmissionValidation"; + export type ResponseData = { responseId: number; responses: FORM_SUBMITTED_WEBHOOK_RESPONSES; @@ -15,13 +17,6 @@ export type ResponseData = { }; }; -export function getSubmitterEmail(responses: any) { - return Object.values(responses).find((response): response is { value: string; label: string } => { - const value = typeof response === "object" && response && "value" in response ? response.value : response; - return typeof value === "string" && value.includes("@"); - })?.value; -} - export const ZTriggerFormSubmittedNoEventWebhookPayloadSchema = z.object({ webhook: z.object({ subscriberUrl: z.string().url(), @@ -47,53 +42,14 @@ export const ZTriggerFormSubmittedNoEventWebhookPayloadSchema = z.object({ export async function triggerFormSubmittedNoEventWebhook(payload: string): Promise { const { webhook, responseId, form, redirect, responses } = ZTriggerFormSubmittedNoEventWebhookPayloadSchema.parse(JSON.parse(payload)); - const bookingFromResponse = await prisma.booking.findFirst({ - where: { - routedFromRoutingFormReponse: { - id: responseId, - }, - }, + + const shouldTrigger = await shouldTriggerFormSubmittedNoEvent({ + formId: form.id, + responses, + responseId, }); - if (bookingFromResponse) { - return; - } - - const sixtyMinutesAgo = new Date(Date.now() - 60 * 60 * 1000); - const recentResponses = - (await prisma.app_RoutingForms_FormResponse.findMany({ - where: { - formId: form.id, - createdAt: { - gte: sixtyMinutesAgo, - lt: new Date(), - }, - routedToBookingUid: { - not: null, - }, - NOT: { - id: responseId, - }, - }, - })) ?? []; - - const emailValue = getSubmitterEmail(responses); - // Check for duplicate email in recent responses - const hasDuplicate = - emailValue && - recentResponses.some((response) => { - return Object.values(response.response as Record).some( - (field) => { - if (!response.response || typeof response.response !== "object") return false; - - return typeof field.value === "string" && field.value.toLowerCase() === emailValue.toLowerCase(); - } - ); - }); - - if (hasDuplicate) { - return; - } + if (!shouldTrigger) return; await sendGenericWebhookPayload({ secretKey: webhook.secret, @@ -126,6 +82,7 @@ export async function triggerFormSubmittedNoEventWebhook(payload: string): Promi // Get action function const bookingActionFunction = incompleteBookingActionFunctions[actionType]; + const emailValue = getSubmitterEmail(responses); if (emailValue) { await bookingActionFunction(incompleteBookingAction, emailValue); } diff --git a/packages/features/tasker/tasks/triggerFormSubmittedNoEvent/triggerFormSubmittedNoEventWorkflow.test.ts b/packages/features/tasker/tasks/triggerFormSubmittedNoEvent/triggerFormSubmittedNoEventWorkflow.test.ts new file mode 100644 index 0000000000..d4777a6d55 --- /dev/null +++ b/packages/features/tasker/tasks/triggerFormSubmittedNoEvent/triggerFormSubmittedNoEventWorkflow.test.ts @@ -0,0 +1,177 @@ +import prismaMock from "../../../../../tests/libs/__mocks__/prismaMock"; + +import { describe, expect, it, beforeEach, vi } from "vitest"; +import type { z } from "zod"; + +import { scheduleWorkflowReminders } from "@calcom/ee/workflows/lib/reminders/reminderScheduler"; +import type { ZTriggerFormSubmittedNoEventWorkflowPayloadSchema } from "@calcom/features/tasker/tasks/triggerFormSubmittedNoEvent/triggerFormSubmittedNoEventWorkflow"; +import { triggerFormSubmittedNoEventWorkflow } from "@calcom/features/tasker/tasks/triggerFormSubmittedNoEvent/triggerFormSubmittedNoEventWorkflow"; +import { WorkflowTriggerEvents, WorkflowActions, WorkflowTemplates, TimeUnit } from "@calcom/prisma/enums"; + +import { shouldTriggerFormSubmittedNoEvent } from "./formSubmissionValidation"; + +// Mock the scheduleWorkflowReminders function +vi.mock("@calcom/ee/workflows/lib/reminders/reminderScheduler", () => ({ + scheduleWorkflowReminders: vi.fn(() => Promise.resolve()), +})); + +// Mock the form submission validation +vi.mock("./formSubmissionValidation", () => ({ + shouldTriggerFormSubmittedNoEvent: vi.fn(() => Promise.resolve(true)), +})); + +// Mock the logger +vi.mock("@calcom/lib/logger", () => ({ + default: { + getSubLogger: vi.fn(() => ({ error: vi.fn() })), + error: vi.fn(), + }, +})); + +const mockScheduleWorkflowReminders = vi.mocked(scheduleWorkflowReminders); +const mockShouldTriggerFormSubmittedNoEvent = vi.mocked(shouldTriggerFormSubmittedNoEvent); + +type WorkflowPayload = z.infer; + +function expectFormSubmittedNoEventWorkflowToBeCalled(payload: WorkflowPayload) { + expect(mockScheduleWorkflowReminders).toHaveBeenCalledWith( + expect.objectContaining({ + workflows: [payload.workflow], + formData: { + responses: payload.responses, + user: { + email: payload.form.user.email, + timeFormat: payload.form.user.timeFormat, + locale: payload.form.user.locale ?? "en", + }, + }, + hideBranding: payload.hideBranding, + smsReminderNumber: payload.smsReminderNumber, + }) + ); +} + +describe("Form submitted, no event booked workflow trigger", () => { + beforeEach(() => { + vi.clearAllMocks(); + // Mock the form response queries to return empty arrays by default + prismaMock.app_RoutingForms_FormResponse.findMany.mockResolvedValue([]); + }); + + it(`should trigger workflow when form was submitted but no booking was made`, async () => { + const payload: WorkflowPayload = { + responseId: 1, + form: { + id: "1234", + userId: 1, + teamId: null, + fields: [{ type: "text", identifier: "Test field 1" }], + user: { + email: "test@example.com", + timeFormat: 12, + locale: "en", + }, + }, + responses: { + "Test field 1": { + value: "Test input 1", + response: "Test input 1", + }, + }, + hideBranding: false, + smsReminderNumber: null, + submittedAt: new Date("2024-01-01T10:00:00Z"), + workflow: { + id: 1, + name: "Test Workflow 1", + teamId: null, + trigger: WorkflowTriggerEvents.FORM_SUBMITTED_NO_EVENT, + time: 15, + timeUnit: TimeUnit.MINUTE, + userId: 1, + steps: [ + { + id: 1, + action: WorkflowActions.EMAIL_ATTENDEE, + sendTo: null, + template: WorkflowTemplates.CUSTOM, + reminderBody: "Follow up on your form submission", + emailSubject: "Follow Up", + sender: null, + includeCalendarEvent: false, + numberVerificationPending: false, + numberRequired: false, + verifiedAt: null, + }, + ], + }, + }; + const payloadString = JSON.stringify(payload); + + // Mock that no booking exists + prismaMock.booking.findFirst.mockResolvedValue(null); + + await triggerFormSubmittedNoEventWorkflow(payloadString); + + expectFormSubmittedNoEventWorkflowToBeCalled(payload); + }); + + it(`should not trigger workflow when form was submitted and also booking was made after`, async () => { + const payload: WorkflowPayload = { + responseId: 2, + form: { + id: "6789", + userId: 2, + teamId: null, + fields: [{ type: "text", identifier: "Test field 2" }], + user: { + email: "test2@example.com", + timeFormat: 24, + locale: "en", + }, + }, + responses: { + "Test field 2": { + value: "Test input 2", + response: "Test input 2", + }, + }, + hideBranding: false, + smsReminderNumber: null, + submittedAt: new Date("2024-01-01T11:00:00Z"), + workflow: { + id: 2, + name: "Test Workflow 2", + teamId: null, + trigger: WorkflowTriggerEvents.FORM_SUBMITTED_NO_EVENT, + time: 30, + timeUnit: TimeUnit.MINUTE, + userId: 2, + steps: [ + { + id: 2, + action: WorkflowActions.SMS_ATTENDEE, + sendTo: null, + template: WorkflowTemplates.CUSTOM, + reminderBody: "SMS follow up", + emailSubject: null, + sender: null, + includeCalendarEvent: false, + numberVerificationPending: false, + numberRequired: false, + verifiedAt: null, + }, + ], + }, + }; + const payloadString = JSON.stringify(payload); + + // Mock that validation should not trigger (booking exists) + mockShouldTriggerFormSubmittedNoEvent.mockResolvedValue(false); + + await triggerFormSubmittedNoEventWorkflow(payloadString); + + // Should not call scheduleWorkflowReminders when validation fails + expect(mockScheduleWorkflowReminders).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/features/tasker/tasks/triggerFormSubmittedNoEvent/triggerFormSubmittedNoEventWorkflow.ts b/packages/features/tasker/tasks/triggerFormSubmittedNoEvent/triggerFormSubmittedNoEventWorkflow.ts new file mode 100644 index 0000000000..2608ccc8b8 --- /dev/null +++ b/packages/features/tasker/tasks/triggerFormSubmittedNoEvent/triggerFormSubmittedNoEventWorkflow.ts @@ -0,0 +1,58 @@ +import { z } from "zod"; + +import { scheduleWorkflowReminders } from "@calcom/ee/workflows/lib/reminders/reminderScheduler"; +import type { Workflow } from "@calcom/ee/workflows/lib/types"; +import logger from "@calcom/lib/logger"; +import { ZWorkflow } from "@calcom/trpc/server/routers/viewer/workflows/getAllActiveWorkflows.schema"; + +import { shouldTriggerFormSubmittedNoEvent } from "./formSubmissionValidation"; + +const log = logger.getSubLogger({ prefix: ["[tasker] triggerFormSubmittedNoEventWorkflow"] }); + +export const ZTriggerFormSubmittedNoEventWorkflowPayloadSchema = z.object({ + responseId: z.number(), + responses: z.any(), + form: z.object({ + id: z.string(), + userId: z.number(), + teamId: z.number().nullable().optional(), + fields: z.array(z.object({ type: z.string(), identifier: z.string().optional() })).optional(), + user: z.object({ + email: z.string(), + timeFormat: z.number().nullable(), + locale: z.string().nullable(), + }), + }), + hideBranding: z.boolean(), + smsReminderNumber: z.string().nullable(), + workflow: ZWorkflow, + submittedAt: z.coerce.date(), +}); + +export async function triggerFormSubmittedNoEventWorkflow(payload: string): Promise { + const { responseId, form, responses, smsReminderNumber, hideBranding, workflow, submittedAt } = + ZTriggerFormSubmittedNoEventWorkflowPayloadSchema.parse(JSON.parse(payload)); + + const shouldTrigger = await shouldTriggerFormSubmittedNoEvent({ + formId: form.id, + responseId, + responses, + submittedAt, + }); + + if (!shouldTrigger) return; + + try { + await scheduleWorkflowReminders({ + smsReminderNumber, + formData: { + responses, + user: { email: form.user.email, timeFormat: form.user.timeFormat, locale: form.user.locale ?? "en" }, + }, + hideBranding, + workflows: [workflow as Workflow], + }); + } catch (error) { + log.error("Error while triggering form submitted no event workflows", JSON.stringify({ error })); + } +} diff --git a/packages/lib/server/repository/formResponse.ts b/packages/lib/server/repository/formResponse.ts index b31dfc034d..fd8a3de9db 100644 --- a/packages/lib/server/repository/formResponse.ts +++ b/packages/lib/server/repository/formResponse.ts @@ -127,4 +127,36 @@ export class RoutingFormResponseRepository { }, }); } + + async findAllResponsesWithBooking({ + formId, + responseId, + createdAfter, + createdBefore, + }: { + formId: string; + responseId: number; + createdAfter: Date; + createdBefore: Date; + }) { + return await this.prismaClient.app_RoutingForms_FormResponse.findMany({ + where: { + formId, + createdAt: { + gte: createdAfter, + lt: createdBefore, + }, + routedToBookingUid: { + not: null, + }, + NOT: { + id: responseId, + }, + }, + select: { + id: true, + response: true, + }, + }); + } } diff --git a/packages/lib/server/service/workflows.test.ts b/packages/lib/server/service/workflows.test.ts index 55ca7bd504..fa08775593 100644 --- a/packages/lib/server/service/workflows.test.ts +++ b/packages/lib/server/service/workflows.test.ts @@ -2,7 +2,7 @@ import { describe, expect, vi, beforeEach } from "vitest"; import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler"; import { tasker } from "@calcom/features/tasker"; -import { WorkflowTriggerEvents, WorkflowActions, WorkflowTemplates } from "@calcom/prisma/enums"; +import { WorkflowTriggerEvents, WorkflowActions, WorkflowTemplates, TimeUnit } from "@calcom/prisma/enums"; import { test } from "@calcom/web/test/fixtures/fixtures"; import { WorkflowService } from "./workflows"; @@ -93,6 +93,67 @@ describe("WorkflowService.scheduleFormWorkflows", () => { }); }); + test("should create task for FORM_SUBMITTED_NO_EVENT triggers", async () => { + const workflows = [ + { + id: 2, + name: "Form Follow-up", + userId: 101, + teamId: null, + trigger: WorkflowTriggerEvents.FORM_SUBMITTED_NO_EVENT, + time: 30, + timeUnit: TimeUnit.MINUTE, + steps: [ + { + id: 2, + action: WorkflowActions.EMAIL_ATTENDEE, + sendTo: null, + reminderBody: "Follow up message", + emailSubject: "Follow Up", + template: WorkflowTemplates.CUSTOM, + verifiedAt: new Date(), + includeCalendarEvent: false, + numberVerificationPending: false, + numberRequired: false, + sender: null, + }, + ], + }, + ]; + + mockTasker.create.mockResolvedValue({ id: "task-123" }); + + await WorkflowService.scheduleFormWorkflows({ + workflows, + responses: mockResponses, + responseId: 123, + form: mockForm, + }); + + expect(mockTasker.create).toHaveBeenCalledWith( + "triggerFormSubmittedNoEventWorkflow", + { + responseId: 123, + responses: mockResponses, + smsReminderNumber: "+1234567890", + hideBranding: false, + submittedAt: expect.any(Date), + form: { + id: "form-123", + userId: 101, + teamId: undefined, + user: { + email: "formowner@example.com", + timeFormat: 12, + locale: "en", + }, + }, + workflow: workflows[0], + }, + { scheduledAt: expect.any(Date) } + ); + }); + test("should handle forms without phone fields by passing null smsReminderNumber", async () => { const formWithoutPhone = { ...mockForm, diff --git a/packages/lib/server/service/workflows.ts b/packages/lib/server/service/workflows.ts index 4c41a6e223..d55b937206 100644 --- a/packages/lib/server/service/workflows.ts +++ b/packages/lib/server/service/workflows.ts @@ -1,7 +1,10 @@ +import dayjs from "@calcom/dayjs"; import { getAllWorkflows } from "@calcom/ee/workflows/lib/getAllWorkflows"; import type { ScheduleWorkflowRemindersArgs } from "@calcom/ee/workflows/lib/reminders/reminderScheduler"; import { scheduleWorkflowReminders } from "@calcom/ee/workflows/lib/reminders/reminderScheduler"; +import type { timeUnitLowerCase } from "@calcom/ee/workflows/lib/reminders/smsReminderManager"; import type { Workflow } from "@calcom/ee/workflows/lib/types"; +import { tasker } from "@calcom/features/tasker"; import { WorkflowRepository } from "@calcom/features/ee/workflows/repositories/WorkflowRepository"; import { getHideBranding } from "@calcom/features/profile/lib/hideBranding"; import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId"; @@ -83,7 +86,9 @@ export class WorkflowService { workflows, responses, form, + responseId, }: { + responseId: number; workflows: Workflow[]; responses: FORM_SUBMITTED_WEBHOOK_RESPONSES; form: { @@ -131,6 +136,44 @@ export class WorkflowService { hideBranding, workflows: workflowsToTrigger, }); + + const workflowsToSchedule: Workflow[] = []; + + workflowsToSchedule.push( + ...workflows.filter((workflow) => workflow.trigger === WorkflowTriggerEvents.FORM_SUBMITTED_NO_EVENT) + ); + + const promisesFormSubmittedNoEvent = workflowsToSchedule.map((workflow) => { + const timeUnit: timeUnitLowerCase = (workflow.timeUnit?.toLowerCase() as timeUnitLowerCase) ?? "minute"; + + const scheduledAt = dayjs() + .add(workflow.time ?? 15, timeUnit) + .toDate(); + + return tasker.create( + "triggerFormSubmittedNoEventWorkflow", + { + responseId, + responses, + smsReminderNumber, + hideBranding, + form: { + id: form.id, + userId: form.userId, + teamId: form.teamId ?? undefined, + user: { + email: form.user.email, + timeFormat: form.user.timeFormat, + locale: form.user.locale ?? "en", + }, + }, + workflow, + submittedAt: new Date(), + }, + { scheduledAt } + ); + }); + await Promise.all(promisesFormSubmittedNoEvent); } static async scheduleWorkflowsForNewBooking({ diff --git a/packages/prisma/migrations/20250909134440_add_form_submitted_no_event/migration.sql b/packages/prisma/migrations/20250909134440_add_form_submitted_no_event/migration.sql new file mode 100644 index 0000000000..856ed82222 --- /dev/null +++ b/packages/prisma/migrations/20250909134440_add_form_submitted_no_event/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "WorkflowTriggerEvents" ADD VALUE 'FORM_SUBMITTED_NO_EVENT'; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index d4e8a01ca5..3b5333d21d 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -1391,6 +1391,7 @@ enum WorkflowTriggerEvents { AFTER_HOSTS_CAL_VIDEO_NO_SHOW AFTER_GUESTS_CAL_VIDEO_NO_SHOW FORM_SUBMITTED + FORM_SUBMITTED_NO_EVENT BOOKING_REJECTED BOOKING_REQUESTED BOOKING_PAYMENT_INITIATED