diff --git a/apps/api/v2/src/modules/workflows/inputs/create-workflow.input.ts b/apps/api/v2/src/modules/workflows/inputs/create-workflow.input.ts index 9c0fcd22dbd700..f75c83a737231c 100644 --- a/apps/api/v2/src/modules/workflows/inputs/create-workflow.input.ts +++ b/apps/api/v2/src/modules/workflows/inputs/create-workflow.input.ts @@ -39,6 +39,8 @@ import { BOOKING_REJECTED, BOOKING_REQUESTED, EVENT_CANCELLED, + FORM_SUBMITTED, + FORM_SUBMITTED_NO_EVENT, NEW_EVENT, OnAfterCalVideoGuestsNoShowTriggerDto, OnAfterCalVideoHostsNoShowTriggerDto, @@ -46,6 +48,8 @@ import { OnBeforeEventTriggerDto, OnCancelTriggerDto, OnCreationTriggerDto, + OnFormSubmittedNoEventTriggerDto, + OnFormSubmittedTriggerDto, OnNoShowUpdateTriggerDto, OnPaidTriggerDto, OnPaymentInitiatedTriggerDto, @@ -83,6 +87,8 @@ export type TriggerDtoType = | OnRescheduleTriggerDto | OnCancelTriggerDto | OnAfterCalVideoGuestsNoShowTriggerDto + | OnFormSubmittedTriggerDto + | OnFormSubmittedNoEventTriggerDto | OnRejectedTriggerDto | OnRequestedTriggerDto | OnPaymentInitiatedTriggerDto @@ -93,6 +99,8 @@ export type TriggerDtoType = @ApiExtraModels( OnBeforeEventTriggerDto, OnAfterEventTriggerDto, + OnFormSubmittedTriggerDto, + OnFormSubmittedNoEventTriggerDto, OnCancelTriggerDto, OnCreationTriggerDto, OnRescheduleTriggerDto, @@ -132,6 +140,8 @@ export class CreateWorkflowDto { { $ref: getSchemaPath(OnRescheduleTriggerDto) }, { $ref: getSchemaPath(OnAfterCalVideoGuestsNoShowTriggerDto) }, { $ref: getSchemaPath(OnAfterCalVideoHostsNoShowTriggerDto) }, + { $ref: getSchemaPath(OnFormSubmittedTriggerDto) }, + { $ref: getSchemaPath(OnFormSubmittedNoEventTriggerDto) }, { $ref: getSchemaPath(OnRejectedTriggerDto) }, { $ref: getSchemaPath(OnRequestedTriggerDto) }, { $ref: getSchemaPath(OnPaidTriggerDto) }, @@ -151,6 +161,8 @@ 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: OnFormSubmittedTriggerDto, name: FORM_SUBMITTED }, + { value: OnFormSubmittedNoEventTriggerDto, name: FORM_SUBMITTED_NO_EVENT }, { value: OnRequestedTriggerDto, name: BOOKING_REQUESTED }, { value: OnRejectedTriggerDto, name: BOOKING_REJECTED }, { value: OnPaymentInitiatedTriggerDto, name: BOOKING_PAYMENT_INITIATED }, @@ -171,7 +183,8 @@ export class CreateWorkflowDto { | OnPaymentInitiatedTriggerDto | OnNoShowUpdateTriggerDto | OnAfterCalVideoGuestsNoShowTriggerDto - | OnAfterCalVideoHostsNoShowTriggerDto; + | OnFormSubmittedTriggerDto + | OnFormSubmittedNoEventTriggerDto; @ApiProperty({ description: "Steps to execute as part of the workflow", diff --git a/apps/api/v2/src/modules/workflows/inputs/update-workflow.input.ts b/apps/api/v2/src/modules/workflows/inputs/update-workflow.input.ts index 124861bae8aa4c..3529f4ffa85049 100644 --- a/apps/api/v2/src/modules/workflows/inputs/update-workflow.input.ts +++ b/apps/api/v2/src/modules/workflows/inputs/update-workflow.input.ts @@ -36,6 +36,10 @@ import { AFTER_GUESTS_CAL_VIDEO_NO_SHOW, OnAfterCalVideoHostsNoShowTriggerDto, AFTER_HOSTS_CAL_VIDEO_NO_SHOW, + OnFormSubmittedNoEventTriggerDto, + OnFormSubmittedTriggerDto, + FORM_SUBMITTED_NO_EVENT, + FORM_SUBMITTED, OnNoShowUpdateTriggerDto, OnRejectedTriggerDto, OnRequestedTriggerDto, @@ -126,6 +130,8 @@ export class UpdateWhatsAppAttendeePhoneWorkflowStepDto extends WorkflowPhoneWha @ApiExtraModels( OnBeforeEventTriggerDto, OnAfterEventTriggerDto, + OnFormSubmittedTriggerDto, + OnFormSubmittedNoEventTriggerDto, OnCancelTriggerDto, OnCreationTriggerDto, OnRescheduleTriggerDto, @@ -170,6 +176,8 @@ export class UpdateWorkflowDto { { $ref: getSchemaPath(OnRescheduleTriggerDto) }, { $ref: getSchemaPath(OnAfterCalVideoGuestsNoShowTriggerDto) }, { $ref: getSchemaPath(OnAfterCalVideoHostsNoShowTriggerDto) }, + { $ref: getSchemaPath(OnFormSubmittedTriggerDto) }, + { $ref: getSchemaPath(OnFormSubmittedNoEventTriggerDto) }, { $ref: getSchemaPath(OnRejectedTriggerDto) }, { $ref: getSchemaPath(OnRequestedTriggerDto) }, { $ref: getSchemaPath(OnPaidTriggerDto) }, @@ -190,6 +198,8 @@ 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: OnFormSubmittedTriggerDto, name: FORM_SUBMITTED }, + { value: OnFormSubmittedNoEventTriggerDto, name: FORM_SUBMITTED_NO_EVENT }, { value: OnRequestedTriggerDto, name: BOOKING_REQUESTED }, { value: OnRejectedTriggerDto, name: BOOKING_REJECTED }, { value: OnPaymentInitiatedTriggerDto, name: BOOKING_PAYMENT_INITIATED }, 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 061cba1cd3974d..f2854154f030f7 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 @@ -11,6 +11,8 @@ 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 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"; @@ -24,6 +26,8 @@ export const WORKFLOW_TRIGGER_TYPES = [ RESCHEDULE_EVENT, 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, @@ -39,6 +43,8 @@ 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, + [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, @@ -54,6 +60,8 @@ 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.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, @@ -213,3 +221,22 @@ export class OnAfterCalVideoHostsNoShowTriggerDto extends TriggerOffsetDTO { @IsIn([AFTER_HOSTS_CAL_VIDEO_NO_SHOW]) type: typeof AFTER_HOSTS_CAL_VIDEO_NO_SHOW = AFTER_HOSTS_CAL_VIDEO_NO_SHOW; } +export class OnFormSubmittedTriggerDto { + @ApiProperty({ + description: "Trigger type for the workflow", + example: FORM_SUBMITTED, + }) + @IsString() + @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; +} 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 64acd8081482d0..7c04ca9a97b8f8 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 @@ -29,6 +29,7 @@ import { import { OnAfterEventTriggerDto, OnBeforeEventTriggerDto, + OnFormSubmittedNoEventTriggerDto, TIME_UNIT_TO_ENUM, WORKFLOW_TRIGGER_TO_ENUM, } from "../inputs/workflow-trigger.input"; @@ -136,22 +137,25 @@ export class WorkflowsInputService { : currentData.trigger; const timeUnitForZod = updateDto.trigger instanceof OnBeforeEventTriggerDto || - updateDto.trigger instanceof OnAfterEventTriggerDto + updateDto.trigger instanceof OnAfterEventTriggerDto || + updateDto.trigger instanceof OnFormSubmittedNoEventTriggerDto ? updateDto?.trigger?.offset?.unit ?? currentData.timeUnit ?? null : undefined; const updateData: TUpdateInputSchema = { id: workflowIdToUse, name: updateDto.name ?? currentData.name, - activeOn: + activeOnEventTypeIds: updateDto?.activation?.activeOnEventTypeIds ?? currentData?.activeOn.map((active) => active.eventTypeId) ?? [], + activeOnRoutingFormIds: updateDto?.activation?.activeOnRoutingFormIds ?? [], steps: mappedSteps, trigger: triggerForZod, time: updateDto.trigger instanceof OnBeforeEventTriggerDto || - updateDto.trigger instanceof OnAfterEventTriggerDto + updateDto.trigger instanceof OnAfterEventTriggerDto || + updateDto.trigger instanceof OnFormSubmittedNoEventTriggerDto ? updateDto?.trigger?.offset?.value ?? currentData?.time ?? null : null, timeUnit: timeUnitForZod ? TIME_UNIT_TO_ENUM[timeUnitForZod] : null, diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index d90113fd23a6e8..860ef333cf0e7c 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -1432,6 +1432,7 @@ "new_event_trigger": "when new event is booked", "email_host_action": "send email to host", "email_attendee_action": "send email to attendees", + "email_attendee_action_form": "Send email to submitted email address", "sms_attendee_action": "Send SMS to attendee", "sms_number_action": "send SMS to a specific number", "send_reminder_sms": "Easily send meeting reminders via SMS to your attendees", @@ -1484,6 +1485,8 @@ "may_require_confirmation": "May require confirmation", "nr_event_type_one": "{{count}} event type", "nr_event_type_other": "{{count}} event types", + "nr_routing_form_one": "{{count}} routing form", + "nr_routing_form_other": "{{count}} routing forms", "count_team_one": "{{count}} team", "count_team_other": "{{count}} teams", "add_action": "Add action", @@ -1600,7 +1603,9 @@ "2fa_required": "Two factor authentication required", "incorrect_2fa": "Incorrect two factor authentication code", "which_event_type_apply": "Which event type will this apply to?", + "which_routing_form_apply": "Which routing form will this apply to?", "apply_to_all_event_types": "Apply to all, including future event types", + "apply_to_all_routing_forms": "Apply to all, including future routing forms", "apply_to_all_teams": "Apply to all team and user event types", "which_team_apply": "Which team will this apply to?", "no_workflows_description": "Workflows enable simple automation to send notifications & reminders enabling you to build processes around your events.", @@ -3602,6 +3607,9 @@ "webhook_metadata": "Metadata", "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 1824e21c82798c..c62024a423dab6 100644 --- a/packages/app-store/routing-forms/lib/formSubmissionUtils.test.ts +++ b/packages/app-store/routing-forms/lib/formSubmissionUtils.test.ts @@ -4,7 +4,14 @@ import { describe, it, vi, expect, beforeEach, afterEach } from "vitest"; import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload"; -import { WebhookTriggerEvents } from "@calcom/prisma/enums"; +import { WorkflowService } from "@calcom/lib/server/service/workflows"; +import { + WebhookTriggerEvents, + WorkflowTriggerEvents, + WorkflowActions, + WorkflowTemplates, +} from "@calcom/prisma/enums"; +import { getAllWorkflowsFromRoutingForm } from "@calcom/trpc/server/routers/viewer/workflows/util"; import { _onFormSubmission } from "./formSubmissionUtils"; @@ -25,6 +32,16 @@ vi.mock("@calcom/features/tasker", () => { return { default: Promise.resolve(tasker) }; }); +// Mock workflow dependencies +vi.mock("@calcom/lib/server/service/workflows", () => ({ + WorkflowService: { + scheduleFormWorkflows: vi.fn(() => Promise.resolve()), + }, +})); +vi.mock("@calcom/trpc/server/routers/viewer/workflows/util", () => ({ + getAllWorkflowsFromRoutingForm: vi.fn(() => Promise.resolve([])), +})); + const mockSendEmail = vi.fn(() => Promise.resolve()); const mockResponseEmailConstructor = vi.fn(); vi.mock("../emails/templates/response-email", () => ({ @@ -44,7 +61,7 @@ describe("_onFormSubmission", () => { { id: "field-1", identifier: "email", label: "Email", type: "email" }, { id: "field-2", identifier: "name", label: "Name", type: "text" }, ], - user: { id: 1, email: "test@example.com" }, + user: { id: 1, email: "test@example.com", timeFormat: 12, locale: "en" }, teamId: null, settings: { emailOwnerOnSubmission: true }, }; @@ -122,13 +139,119 @@ describe("_onFormSubmission", () => { }); }); + describe("Workflows", () => { + it("should call WorkflowService.scheduleFormWorkflows for FORM_SUBMITTED workflows", async () => { + const mockWorkflows = [ + { + id: 1, + name: "Form Submitted Workflow", + userId: 1, + teamId: null, + trigger: WorkflowTriggerEvents.FORM_SUBMITTED, + time: null, + timeUnit: null, + steps: [ + { + id: 1, + action: WorkflowActions.EMAIL_ATTENDEE, + sendTo: null, + reminderBody: "Thank you for your submission!", + emailSubject: "Form Received", + template: WorkflowTemplates.CUSTOM, + verifiedAt: new Date(), + includeCalendarEvent: false, + numberVerificationPending: false, + numberRequired: false, + }, + ], + }, + ]; + + vi.mocked(getAllWorkflowsFromRoutingForm).mockResolvedValueOnce(mockWorkflows as any); + + await _onFormSubmission(mockForm as any, mockResponse, responseId); + + expect(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) => ({ + 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(getAllWorkflowsFromRoutingForm).mockResolvedValueOnce(mockWorkflows as any); + + await _onFormSubmission(mockForm as any, mockResponse, responseId); + + expect(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) => ({ + type: field.type, + identifier: field.identifier, + })), + }, + }); + }); + }); + describe("Response Email", () => { it("should send response email to team members for a team form", async () => { const teamForm = { ...mockForm, teamId: 1, userWithEmails: ["team-member1@example.com", "team-member2@example.com"], - user: { id: 1, email: "test@example.com" }, + user: { id: 1, email: "test@example.com", timeFormat: 12, locale: "en" }, }; await _onFormSubmission(teamForm as any, mockResponse, responseId); diff --git a/packages/app-store/routing-forms/lib/formSubmissionUtils.ts b/packages/app-store/routing-forms/lib/formSubmissionUtils.ts index dc177222d0f4e3..449e61af9de5c0 100644 --- a/packages/app-store/routing-forms/lib/formSubmissionUtils.ts +++ b/packages/app-store/routing-forms/lib/formSubmissionUtils.ts @@ -1,6 +1,7 @@ import type { Prisma } from "@prisma/client"; import type { App_RoutingForms_Form, User } from "@prisma/client"; +import getFieldIdentifier from "@calcom/app-store/routing-forms/lib/getFieldIdentifier"; import dayjs from "@calcom/dayjs"; import type { Tasker } from "@calcom/features/tasker/tasker"; import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; @@ -8,9 +9,11 @@ import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPay import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId"; import logger from "@calcom/lib/logger"; import { withReporting } from "@calcom/lib/sentryWrapper"; +import { WorkflowService } from "@calcom/lib/server/service/workflows"; import { prisma } from "@calcom/prisma"; import { WebhookTriggerEvents } from "@calcom/prisma/enums"; import { RoutingFormSettings } from "@calcom/prisma/zod-utils"; +import { getAllWorkflowsFromRoutingForm } from "@calcom/trpc/server/routers/viewer/workflows/util"; import type { Ensure } from "@calcom/types/utils"; import { TRPCError } from "@trpc/server"; @@ -114,7 +117,10 @@ function getWebhookTargetEntity(form: { teamId?: number | null; user: { id: numb */ export async function _onFormSubmission( form: Ensure< - SerializableForm & { user: Pick; userWithEmails?: string[] }, + SerializableForm & { + user: Pick; + userWithEmails?: string[]; + }, "fields" >, response: FormResponse, @@ -213,6 +219,22 @@ export async function _onFormSubmission( const promises = [...promisesFormSubmitted, ...promisesFormSubmittedNoEvent]; await Promise.all(promises); + + const workflows = await getAllWorkflowsFromRoutingForm(form); + + await WorkflowService.scheduleFormWorkflows({ + workflows, + responses: fieldResponsesByIdentifier, + responseId, + form: { + ...form, + fields: form.fields.map((field) => ({ + type: field.type, + identifier: getFieldIdentifier(field), + })), + }, + }); + const orderedResponses = form.fields.reduce((acc, field) => { acc.push(response[field.id]); return acc; @@ -245,6 +267,8 @@ export type TargetRoutingFormForResponse = SerializableForm< user: { id: number; email: string; + timeFormat: number | null; + locale: string | null; }; team: { parentId: number | null; diff --git a/packages/app-store/routing-forms/lib/handleResponse.test.ts b/packages/app-store/routing-forms/lib/handleResponse.test.ts index de80831b79a504..16f6d7f9629e29 100644 --- a/packages/app-store/routing-forms/lib/handleResponse.test.ts +++ b/packages/app-store/routing-forms/lib/handleResponse.test.ts @@ -81,6 +81,8 @@ const mockForm: TargetRoutingFormForResponse = { user: { id: 1, email: "test@example.com", + timeFormat: null, + locale: null, }, team: { parentId: 2, diff --git a/packages/features/ee/workflows/components/AddActionDialog.tsx b/packages/features/ee/workflows/components/AddActionDialog.tsx index 7f8a262f3779b7..9645777b87c786 100644 --- a/packages/features/ee/workflows/components/AddActionDialog.tsx +++ b/packages/features/ee/workflows/components/AddActionDialog.tsx @@ -10,7 +10,6 @@ import PhoneInput from "@calcom/features/components/phone-input"; import { SENDER_ID, SENDER_NAME } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { WorkflowActions } from "@calcom/prisma/enums"; -import { trpc } from "@calcom/trpc/react"; import { Button } from "@calcom/ui/components/button"; import { DialogContent, DialogFooter, DialogClose } from "@calcom/ui/components/dialog"; import { EmailField } from "@calcom/ui/components/form"; @@ -35,6 +34,13 @@ interface IAddActionDialog { senderId?: string, senderName?: string ) => void; + actionOptions: { + label: string; + value: WorkflowActions; + needsCredits: boolean; + creditsTeamId?: number; + isOrganization?: boolean; + }[]; } interface ISelectActionOption { @@ -52,11 +58,10 @@ type AddActionFormValues = { export const AddActionDialog = (props: IAddActionDialog) => { const { t } = useLocale(); - const { isOpenDialog, setIsOpenDialog, addAction } = props; + const { isOpenDialog, setIsOpenDialog, addAction, actionOptions } = props; const [isPhoneNumberNeeded, setIsPhoneNumberNeeded] = useState(false); const [isSenderIdNeeded, setIsSenderIdNeeded] = useState(false); const [isEmailAddressNeeded, setIsEmailAddressNeeded] = useState(false); - const { data: actionOptions } = trpc.viewer.workflows.getWorkflowActionOptions.useQuery(); const formSchema = z.object({ action: z.enum(WORKFLOW_ACTIONS), @@ -165,9 +170,7 @@ export const AddActionDialog = (props: IAddActionDialog) => { menuPlacement="bottom" defaultValue={actionOptions[0]} onChange={handleSelectAction} - options={actionOptions.map((option) => ({ - ...option, - }))} + options={actionOptions} /> ); }} diff --git a/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx b/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx index e6c03d1a8a8f25..ad47f85ba70c49 100644 --- a/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx +++ b/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx @@ -6,16 +6,23 @@ import { Controller } from "react-hook-form"; import { SENDER_ID, SENDER_NAME, SCANNING_WORKFLOW_STEPS } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import type { WorkflowActions } from "@calcom/prisma/enums"; +import { WorkflowActions } from "@calcom/prisma/enums"; import { WorkflowTemplates } from "@calcom/prisma/enums"; import type { RouterOutputs } from "@calcom/trpc/react"; +import { trpc } from "@calcom/trpc/react"; import { InfoBadge } from "@calcom/ui/components/badge"; import { Button } from "@calcom/ui/components/button"; import type { MultiSelectCheckboxesOptionType as Option } from "@calcom/ui/components/form"; import { Label, MultiSelectCheckbox, TextField, CheckboxField } from "@calcom/ui/components/form"; import { Icon } from "@calcom/ui/components/icon"; -import { isSMSAction, isCalAIAction } from "../lib/actionHelperFunctions"; +import { + isSMSAction, + isCalAIAction, + isFormTrigger, + isWhatsappAction, + hasCalAIAction, +} from "../lib/actionHelperFunctions"; import type { FormValues } from "../pages/workflow"; import { AddActionDialog } from "./AddActionDialog"; import { DeleteDialog } from "./DeleteDialog"; @@ -58,11 +65,6 @@ export default function WorkflowDetailsPage(props: Props) { const { t } = useLocale(); const router = useRouter(); - const hasCalAIAction = () => { - const steps = form.getValues("steps") || []; - return steps.some((step) => isCalAIAction(step.action)); - }; - const permissions = _permissions || { canView: !teamId ? true : !props.readOnly, canUpdate: !teamId ? true : !props.readOnly, @@ -78,6 +80,43 @@ export default function WorkflowDetailsPage(props: Props) { const searchParams = useSearchParams(); const eventTypeId = searchParams?.get("eventTypeId"); + // Get base action options and transform them for form triggers + const { data: baseActionOptions } = trpc.viewer.workflows.getWorkflowActionOptions.useQuery(); + + const transformedActionOptions = + baseActionOptions + ?.filter((option) => { + if ( + (isFormTrigger(form.getValues("trigger")) && + (isSMSAction(option.value) || + option.value === WorkflowActions.EMAIL_HOST || + isCalAIAction(option.value))) || + isWhatsappAction(option.value) || + (isCalAIAction(option.value) && form.watch("selectAll")) || + (isCalAIAction(option.value) && isOrg) + ) { + return false; + } + return true; + }) + .map((option) => { + let label = option.label; + + // Transform labels for form triggers + if (isFormTrigger(form.getValues("trigger"))) { + if (option.value === WorkflowActions.EMAIL_ATTENDEE) { + label = t("email_attendee_action_form"); + } + } + + return { + ...option, + label, + creditsTeamId: teamId, + isOrganization: isOrg, + }; + }) ?? []; + useEffect(() => { const matchingOption = allOptions.find((option) => option.value === eventTypeId); if (matchingOption && !selectedOptions.find((option) => option.value === eventTypeId)) { @@ -150,7 +189,11 @@ export default function WorkflowDetailsPage(props: Props) { ) : ( - + )} { form.setValue("activeOn", s); }} - countText={isOrg ? "count_team" : "nr_event_type"} + countText={ + isOrg + ? "count_team" + : isFormTrigger(form.getValues("trigger")) + ? "nr_routing_form" + : "nr_event_type" + } /> ); }} /> - {!hasCalAIAction() && ( + {!hasCalAIAction(form.getValues("steps")) && (
( { onChange(e); @@ -217,6 +272,7 @@ export default function WorkflowDetailsPage(props: Props) { readOnly={props.readOnly} isOrganization={isOrg} onSaveWorkflow={props.onSaveWorkflow} + actionOptions={transformedActionOptions} />
)} @@ -235,6 +291,7 @@ export default function WorkflowDetailsPage(props: Props) { readOnly={props.readOnly} isOrganization={isOrg} onSaveWorkflow={props.onSaveWorkflow} + actionOptions={transformedActionOptions} /> ); })} @@ -262,6 +319,7 @@ export default function WorkflowDetailsPage(props: Props) { isOpenDialog={isAddActionDialogOpen} setIsOpenDialog={setIsAddActionDialogOpen} addAction={addAction} + actionOptions={transformedActionOptions} /> Promise; + actionOptions: { + label: string; + value: WorkflowActions; + needsCredits: boolean; + creditsTeamId?: number; + isOrganization: boolean; + }[]; }; const getTimeSectionText = (trigger: WorkflowTriggerEvents, t: TFunction) => { @@ -87,9 +96,9 @@ 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", }; - if (!triggerMap[trigger]) return null; - return t(triggerMap[trigger]!); + return triggerMap[trigger] ? t(triggerMap[trigger]) : null; }; const CalAIAgentDataSkeleton = () => { @@ -131,7 +140,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { const router = useRouter(); const searchParams = useSearchParams(); - const { step, form, reload, setReload, teamId, onSaveWorkflow } = props; + const { step, form, reload, setReload, teamId, actionOptions, onSaveWorkflow } = props; const { data: _verifiedNumbers } = trpc.viewer.workflows.getVerifiedNumbers.useQuery( { teamId }, { enabled: !!teamId } @@ -239,7 +248,11 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { : false ); - const [timeSectionText, setTimeSectionText] = useState(getTimeSectionText(form.getValues("trigger"), t)); + const trigger = useWatch({ + control: form.control, + name: "trigger", + }); + const [timeSectionText, setTimeSectionText] = useState(getTimeSectionText(trigger, t)); const [autoAgentCreationAttempted, setAutoAgentCreationAttempted] = useState(false); useEffect(() => { @@ -301,9 +314,24 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { router, ]); - const { data: actionOptions } = trpc.viewer.workflows.getWorkflowActionOptions.useQuery(); const triggerOptions = getWorkflowTriggerOptions(t); - const templateOptions = getWorkflowTemplateOptions(t, step?.action, hasActiveTeamPlan); + const templateOptions = getWorkflowTemplateOptions(t, step?.action, hasActiveTeamPlan, trigger); + + const steps = useWatch({ + control: form.control, + name: "steps", + }); + + const hasAiAction = hasCalAIAction(steps); + const hasSMSAction = steps.some((s) => isSMSAction(s.action)); + const hasEmailToHostAction = steps.some((s) => s.action === WorkflowActions.EMAIL_HOST); + + const disallowFormTriggers = hasAiAction || hasSMSAction || hasEmailToHostAction; + + const filteredTriggerOptions = triggerOptions.filter( + (option) => !(isFormTrigger(option.value) && disallowFormTriggers) + ); + if (step && !form.getValues(`steps.${step.stepNumber - 1}.reminderBody`)) { const action = form.getValues(`steps.${step.stepNumber - 1}.action`); @@ -453,7 +481,6 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { //trigger if (!step) { - const trigger = form.getValues("trigger"); const triggerString = t(`${trigger.toLowerCase()}_trigger`); const selectedTrigger = { @@ -507,10 +534,20 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { form.unregister("time"); form.unregister("timeUnit"); } + if (isFormTrigger(val.value)) { + const steps = form.getValues("steps"); + if (steps && steps.length > 0) { + const updatedSteps = steps.map((step) => ({ + ...step, + template: WorkflowTemplates.CUSTOM, + })); + form.setValue("steps", updatedSteps); + } + } } }} defaultValue={selectedTrigger} - options={triggerOptions} + options={filteredTriggerOptions} /> ); }} @@ -519,7 +556,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
- {!props.readOnly && ( + {!props.readOnly && trigger !== WorkflowTriggerEvents.FORM_SUBMITTED_NO_EVENT && (

{t("testing_workflow_info_message")}

@@ -704,21 +741,10 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { } }} defaultValue={selectedAction} - options={actionOptions - ?.filter((option) => { - if ( - (isCalAIAction(option.value) && form.watch("selectAll")) || - (isCalAIAction(option.value) && props.isOrganization) - ) { - return false; - } - return true; - }) - ?.map((option) => ({ - ...option, - creditsTeamId: teamId ?? creditsTeamId, - isOrganization: props.isOrganization, - }))} + options={actionOptions.map((option) => ({ + ...option, + creditsTeamId: teamId ?? creditsTeamId, + }))} /> ); }} @@ -1106,19 +1132,20 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { onChange={(val) => { if (val) { const action = form.getValues(`steps.${step.stepNumber - 1}.action`); + const value = val.value as WorkflowTemplates; const template = getTemplateBodyForAction({ action, locale: i18n.language, t, - template: val.value ?? WorkflowTemplates.REMINDER, + template: value ?? WorkflowTemplates.REMINDER, timeFormat, }); form.setValue(`steps.${step.stepNumber - 1}.reminderBody`, template); if (shouldScheduleEmailReminder(action)) { - if (val.value === WorkflowTemplates.REMINDER) { + if (value === WorkflowTemplates.REMINDER) { form.setValue( `steps.${step.stepNumber - 1}.emailSubject`, emailReminderTemplate({ @@ -1129,7 +1156,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { timeFormat, }).emailSubject ); - } else if (val.value === WorkflowTemplates.RATING) { + } else if (value === WorkflowTemplates.RATING) { form.setValue( `steps.${step.stepNumber - 1}.emailSubject`, emailRatingTemplate({ @@ -1142,8 +1169,8 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { ); } } - field.onChange(val.value); - form.setValue(`steps.${step.stepNumber - 1}.template`, val.value); + field.onChange(value); + form.setValue(`steps.${step.stepNumber - 1}.template`, value); setUpdateTemplate(!updateTemplate); } }} @@ -1158,7 +1185,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { }))} isOptionDisabled={(option: { label: string; - value: any; + value: string; needsTeamsUpgrade: boolean; }) => option.needsTeamsUpgrade} /> @@ -1262,8 +1289,8 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { />
)} - {!props.readOnly && ( -
+ {!props.readOnly && !isFormTrigger(trigger) && ( +