diff --git a/apps/web/app/api/routing-forms/queued-response/__tests__/queued-response.test.ts b/apps/web/app/api/routing-forms/queued-response/__tests__/queued-response.test.ts index 33ad140de3..613e9e99ba 100644 --- a/apps/web/app/api/routing-forms/queued-response/__tests__/queued-response.test.ts +++ b/apps/web/app/api/routing-forms/queued-response/__tests__/queued-response.test.ts @@ -42,6 +42,7 @@ const mockQueuedFormResponse = { disabled: false, }, chosenRouteId: "mock-chosen-route-id", + fallbackAction: null, createdAt: new Date(), updatedAt: new Date(), response: {}, @@ -126,6 +127,57 @@ describe("queuedResponseHandler", () => { }); }); + it("should pass fallbackAction to onSubmissionOfFormResponse when present", async () => { + const fallbackAction = { + type: "eventTypeRedirectUrl" as const, + value: "team/fallback-event", + eventTypeId: 42, + }; + + vi.mocked(mockRoutingFormResponseRepository.getQueuedFormResponseFromId).mockResolvedValue({ + ...mockQueuedFormResponse, + fallbackAction, + }); + + vi.mocked(getSerializableForm).mockResolvedValue({ + id: "mock-form-id", + name: "Test Form", + description: "Test Form Description", + fields: [], + routes: [], + userId: 1, + teamId: null, + position: 1, + updatedById: null, + disabled: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + settings: {}, + } as unknown as Awaited>); + + vi.mocked(getResponseToStore).mockReturnValue({} as ReturnType); + + vi.mocked(onSubmissionOfFormResponse).mockResolvedValue(undefined as unknown as Awaited< + ReturnType + >); + + vi.mocked(mockRoutingFormResponseRepository.recordFormResponse).mockResolvedValue({ + id: 1, + response: {}, + } as Awaited>); + + await queuedResponseHandler({ + queuedFormResponseId: "1", + params: {}, + }); + + expect(onSubmissionOfFormResponse).toHaveBeenCalledWith( + expect.objectContaining({ + fallbackAction, + }) + ); + }); + it("if no queued form response is found, should return early", async () => { vi.mocked(mockRoutingFormResponseRepository.getQueuedFormResponseFromId).mockResolvedValue(null); const response = await queuedResponseHandler({ diff --git a/apps/web/app/api/routing-forms/queued-response/route.ts b/apps/web/app/api/routing-forms/queued-response/route.ts index d16eaed8b3..772e32cad3 100644 --- a/apps/web/app/api/routing-forms/queued-response/route.ts +++ b/apps/web/app/api/routing-forms/queued-response/route.ts @@ -72,6 +72,12 @@ export const queuedResponseHandler = async ({ } const chosenRoute = serializableForm.routes?.find((r) => r.id === queuedFormResponse.chosenRouteId); + const fallbackAction = queuedFormResponse.fallbackAction as { + type: "customPageMessage" | "externalRedirectUrl" | "eventTypeRedirectUrl"; + value: string; + eventTypeId?: number; + } | null; + await onSubmissionOfFormResponse({ form: { ...queuedFormResponse.form, @@ -79,6 +85,7 @@ export const queuedResponseHandler = async ({ }, formResponseInDb: formResponse, chosenRouteAction: chosenRoute ? ("action" in chosenRoute ? chosenRoute.action : null) : null, + fallbackAction, }); return { diff --git a/apps/web/modules/webhooks/components/WebhookForm.tsx b/apps/web/modules/webhooks/components/WebhookForm.tsx index f98f3f42d6..978427928a 100644 --- a/apps/web/modules/webhooks/components/WebhookForm.tsx +++ b/apps/web/modules/webhooks/components/WebhookForm.tsx @@ -73,6 +73,7 @@ const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP_V2: Record { }); }); }); + +describe("_onFormSubmission with fallbackAction", () => { + const mockForm = { + id: "form-1", + name: "Test Form", + disabled: false, + userId: 1, + position: 0, + description: null, + updatedById: null, + fields: [ + { id: "field-1", identifier: "email", label: "Email", type: "email", required: false }, + ] as Field[], + user: { id: 1, email: "test@example.com", timeFormat: 12, locale: "en" }, + teamId: 1, + settings: {}, + routes: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + connectedForms: [], + routers: [], + teamMembers: [], + }; + + const mockResponse: FormResponse = { + "field-1": { label: "Email", value: "test@response.com" }, + }; + + const mockFallbackAction = { + type: "externalRedirectUrl" as const, + value: "https://example.com/fallback", + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should fetch ROUTING_FORM_FALLBACK_HIT webhooks when fallbackAction is provided", async () => { + vi.mocked(getWebhooks).mockResolvedValue([]); + + await _onFormSubmission(mockForm, mockResponse, 123, undefined, mockFallbackAction); + + expect(getWebhooks).toHaveBeenCalledWith({ + userId: null, + teamId: 1, + orgId: 1, + triggerEvent: WebhookTriggerEvents.ROUTING_FORM_FALLBACK_HIT, + }); + }); + + it("should not fetch ROUTING_FORM_FALLBACK_HIT webhooks when fallbackAction is not provided", async () => { + vi.mocked(getWebhooks).mockResolvedValue([]); + + await _onFormSubmission(mockForm, mockResponse, 123); + + const calls = vi.mocked(getWebhooks).mock.calls; + const fallbackCalls = calls.filter( + (call) => call[0].triggerEvent === WebhookTriggerEvents.ROUTING_FORM_FALLBACK_HIT + ); + expect(fallbackCalls).toHaveLength(0); + }); + + it("should send ROUTING_FORM_FALLBACK_HIT webhook payload with correct data", async () => { + const mockWebhook: WebhookSubscriber = { + id: "wh-1", + secret: "secret", + subscriberUrl: "https://example.com/webhook", + payloadTemplate: null, + appId: null, + eventTriggers: [WebhookTriggerEvents.ROUTING_FORM_FALLBACK_HIT], + time: null, + timeUnit: null, + version: WebhookVersionEnum.V_2021_10_20, + }; + vi.mocked(getWebhooks).mockImplementation(async (opts) => { + if (opts.triggerEvent === WebhookTriggerEvents.ROUTING_FORM_FALLBACK_HIT) { + return [mockWebhook]; + } + return []; + }); + + await _onFormSubmission(mockForm, mockResponse, 123, undefined, mockFallbackAction); + + expect(sendGenericWebhookPayload).toHaveBeenCalledWith( + expect.objectContaining({ + secretKey: "secret", + triggerEvent: "ROUTING_FORM_FALLBACK_HIT", + webhook: mockWebhook, + data: { + formId: "form-1", + formName: "Test Form", + teamId: 1, + responseId: 123, + fallbackAction: { + type: "externalRedirectUrl", + value: "https://example.com/fallback", + }, + responses: mockResponse, + }, + }) + ); + }); + + it("should include eventTypeId in fallbackAction payload when present", async () => { + const mockWebhook: WebhookSubscriber = { + id: "wh-1", + secret: "secret", + subscriberUrl: "https://example.com/webhook", + payloadTemplate: null, + appId: null, + eventTriggers: [WebhookTriggerEvents.ROUTING_FORM_FALLBACK_HIT], + time: null, + timeUnit: null, + version: WebhookVersionEnum.V_2021_10_20, + }; + vi.mocked(getWebhooks).mockImplementation(async (opts) => { + if (opts.triggerEvent === WebhookTriggerEvents.ROUTING_FORM_FALLBACK_HIT) { + return [mockWebhook]; + } + return []; + }); + + const fallbackWithEventType = { + type: "eventTypeRedirectUrl" as const, + value: "team/30min", + eventTypeId: 42, + }; + + await _onFormSubmission(mockForm, mockResponse, 123, undefined, fallbackWithEventType); + + expect(sendGenericWebhookPayload).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + fallbackAction: { + type: "eventTypeRedirectUrl", + value: "team/30min", + eventTypeId: 42, + }, + }), + }) + ); + }); + + it("should send to multiple ROUTING_FORM_FALLBACK_HIT webhooks", async () => { + const mockWebhooks: WebhookSubscriber[] = [ + { + id: "wh-1", + secret: "secret1", + subscriberUrl: "https://example.com/webhook1", + payloadTemplate: null, + appId: null, + eventTriggers: [WebhookTriggerEvents.ROUTING_FORM_FALLBACK_HIT], + time: null, + timeUnit: null, + version: WebhookVersionEnum.V_2021_10_20, + }, + { + id: "wh-2", + secret: "secret2", + subscriberUrl: "https://example.com/webhook2", + payloadTemplate: null, + appId: null, + eventTriggers: [WebhookTriggerEvents.ROUTING_FORM_FALLBACK_HIT], + time: null, + timeUnit: null, + version: WebhookVersionEnum.V_2021_10_20, + }, + ]; + vi.mocked(getWebhooks).mockImplementation(async (opts) => { + if (opts.triggerEvent === WebhookTriggerEvents.ROUTING_FORM_FALLBACK_HIT) { + return mockWebhooks; + } + return []; + }); + + await _onFormSubmission(mockForm, mockResponse, 123, undefined, mockFallbackAction); + + const fallbackCalls = vi.mocked(sendGenericWebhookPayload).mock.calls.filter( + (call) => call[0].triggerEvent === "ROUTING_FORM_FALLBACK_HIT" + ); + expect(fallbackCalls).toHaveLength(2); + }); + + it("should not throw when ROUTING_FORM_FALLBACK_HIT webhook payload fails", async () => { + const mockWebhook: WebhookSubscriber = { + id: "wh-1", + secret: "secret", + subscriberUrl: "https://example.com/webhook", + payloadTemplate: null, + appId: null, + eventTriggers: [WebhookTriggerEvents.ROUTING_FORM_FALLBACK_HIT], + time: null, + timeUnit: null, + version: WebhookVersionEnum.V_2021_10_20, + }; + vi.mocked(getWebhooks).mockImplementation(async (opts) => { + if (opts.triggerEvent === WebhookTriggerEvents.ROUTING_FORM_FALLBACK_HIT) { + return [mockWebhook]; + } + return []; + }); + vi.mocked(sendGenericWebhookPayload).mockRejectedValueOnce(new Error("Network error")); + + await expect( + _onFormSubmission(mockForm, mockResponse, 123, undefined, mockFallbackAction) + ).resolves.not.toThrow(); + }); +}); diff --git a/packages/app-store/routing-forms/lib/formSubmissionUtils.ts b/packages/app-store/routing-forms/lib/formSubmissionUtils.ts index 31b8a77de2..bde9947248 100644 --- a/packages/app-store/routing-forms/lib/formSubmissionUtils.ts +++ b/packages/app-store/routing-forms/lib/formSubmissionUtils.ts @@ -9,13 +9,11 @@ import { HttpError } from "@calcom/lib/http-error"; import logger from "@calcom/lib/logger"; import { withReporting } from "@calcom/lib/sentryWrapper"; import { prisma } from "@calcom/prisma"; -import type { Prisma } from "@calcom/prisma/client"; -import type { App_RoutingForms_Form, User } from "@calcom/prisma/client"; +import type { App_RoutingForms_Form, Prisma, User } from "@calcom/prisma/client"; import { WebhookTriggerEvents } from "@calcom/prisma/enums"; import { RoutingFormSettings } from "@calcom/prisma/zod-utils"; import type { Ensure } from "@calcom/types/utils"; - -import type { FormResponse, SerializableForm, SerializableField, OrderedResponses } from "../types/types"; +import type { FormResponse, OrderedResponses, SerializableField, SerializableForm } from "../types/types"; import getFieldIdentifier from "./getFieldIdentifier"; const moduleLogger = logger.getSubLogger({ prefix: ["routing-forms/lib/formSubmissionUtils"] }); @@ -136,7 +134,12 @@ export async function _onFormSubmission( type: "customPageMessage" | "externalRedirectUrl" | "eventTypeRedirectUrl"; value: string; eventTypeId?: number; - } + }, + fallbackAction?: { + type: "customPageMessage" | "externalRedirectUrl" | "eventTypeRedirectUrl"; + value: string; + eventTypeId?: number; + } | null ) { const fieldResponsesByIdentifier: FORM_SUBMITTED_WEBHOOK_RESPONSES = {}; @@ -174,9 +177,20 @@ export async function _onFormSubmission( triggerEvent: WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT, }; - const webhooksFormSubmitted = await getWebhooks(subscriberOptionsFormSubmitted); + const subscriberOptionsFallbackHit = fallbackAction + ? { + userId, + teamId, + orgId, + triggerEvent: WebhookTriggerEvents.ROUTING_FORM_FALLBACK_HIT, + } + : null; - const webhooksFormSubmittedNoEvent = await getWebhooks(subscriberOptionsFormSubmittedNoEvent); + const [webhooksFormSubmitted, webhooksFormSubmittedNoEvent, webhooksFallbackHit] = await Promise.all([ + getWebhooks(subscriberOptionsFormSubmitted), + getWebhooks(subscriberOptionsFormSubmittedNoEvent), + subscriberOptionsFallbackHit ? getWebhooks(subscriberOptionsFallbackHit) : Promise.resolve([]), + ]); const promisesFormSubmitted = webhooksFormSubmitted.map((webhook) => { sendGenericWebhookPayload({ @@ -192,11 +206,14 @@ export async function _onFormSubmission( }, rootData: { // Send responses unwrapped at root level for backwards compatibility - ...Object.entries(fieldResponsesByIdentifier).reduce((acc, [key, value]) => { - const normalizedKey = normalizeIdentifierForHandlebars(key); - acc[normalizedKey] = value.value; - return acc; - }, {} as Record), + ...Object.entries(fieldResponsesByIdentifier).reduce( + (acc, [key, value]) => { + const normalizedKey = normalizeIdentifierForHandlebars(key); + acc[normalizedKey] = value.value; + return acc; + }, + {} as Record + ), }, }).catch((e) => { console.error(`Error executing routing form webhook`, webhook, e); @@ -226,7 +243,32 @@ export async function _onFormSubmission( ); }); - const promises = [...promisesFormSubmitted, ...promisesFormSubmittedNoEvent]; + const promisesFallbackHit = webhooksFallbackHit.map((webhook) => + sendGenericWebhookPayload({ + secretKey: webhook.secret, + triggerEvent: "ROUTING_FORM_FALLBACK_HIT", + createdAt: new Date().toISOString(), + webhook, + data: { + formId: form.id, + formName: form.name, + teamId: form.teamId, + responseId, + fallbackAction: fallbackAction + ? { + type: fallbackAction.type, + value: fallbackAction.value, + ...(fallbackAction.eventTypeId ? { eventTypeId: fallbackAction.eventTypeId } : {}), + } + : undefined, + responses: response, + }, + }).catch((e) => { + moduleLogger.error(`Error executing ROUTING_FORM_FALLBACK_HIT webhook`, e); + }) + ); + + const promises = [...promisesFormSubmitted, ...promisesFormSubmittedNoEvent, ...promisesFallbackHit]; await Promise.all(promises); @@ -280,7 +322,7 @@ export async function _onFormSubmission( } export const onFormSubmission = withReporting(_onFormSubmission, "onFormSubmission"); -export type TargetRoutingFormForResponse = SerializableForm< +export type TargetRoutingFormForResponse= SerializableForm< App_RoutingForms_Form & { user: { id: number; @@ -301,6 +343,7 @@ export const onSubmissionOfFormResponse = async ({ form, formResponseInDb, chosenRouteAction, + fallbackAction, }: { form: TargetRoutingFormForResponse; formResponseInDb: { id: number; response: Prisma.JsonValue }; @@ -309,6 +352,11 @@ export const onSubmissionOfFormResponse = async ({ value: string; eventTypeId?: number; } | null; + fallbackAction?: { + type: "customPageMessage" | "externalRedirectUrl" | "eventTypeRedirectUrl"; + value: string; + eventTypeId?: number; + } | null; }) => { if (!form.fields) { // There is no point in submitting a form that doesn't have fields defined @@ -339,6 +387,7 @@ export const onSubmissionOfFormResponse = async ({ { ...form, fields: form.fields, userWithEmails }, formResponseInDb.response as FormResponse, formResponseInDb.id, - chosenRouteAction ?? undefined + chosenRouteAction ?? undefined, + fallbackAction ); }; diff --git a/packages/app-store/routing-forms/zod.ts b/packages/app-store/routing-forms/zod.ts index 066494c6b7..d69d1e3b94 100644 --- a/packages/app-store/routing-forms/zod.ts +++ b/packages/app-store/routing-forms/zod.ts @@ -1,14 +1,12 @@ -import { z } from "zod"; - import { raqbQueryValueSchema } from "@calcom/lib/raqb/zod"; - +import { z } from "zod"; import { routingFormAppDataSchemas } from "./appDataSchemas"; export { - zodNonRouterField, - routingFormResponseInDbSchema, type FieldOption, + routingFormResponseInDbSchema, type TNonRouterField, + zodNonRouterField, } from "@calcom/features/routing-forms/lib/zod"; import type { TNonRouterField } from "@calcom/features/routing-forms/lib/zod"; diff --git a/packages/features/routing-forms/lib/handleResponse.test.ts b/packages/features/routing-forms/lib/handleResponse.test.ts index 0112e5055e..f37ded8628 100644 --- a/packages/features/routing-forms/lib/handleResponse.test.ts +++ b/packages/features/routing-forms/lib/handleResponse.test.ts @@ -1,14 +1,11 @@ import "@calcom/lib/__mocks__/logger"; import { prisma } from "@calcom/prisma/__mocks__/prisma"; - -import { beforeEach, describe, expect, it, vi } from "vitest"; - import routerGetCrmContactOwnerEmail from "@calcom/app-store/routing-forms/lib/crmRouting/routerGetCrmContactOwnerEmail"; import type { TargetRoutingFormForResponse } from "@calcom/app-store/routing-forms/lib/formSubmissionUtils"; import { onSubmissionOfFormResponse } from "@calcom/app-store/routing-forms/lib/formSubmissionUtils"; import isRouter from "@calcom/app-store/routing-forms/lib/isRouter"; import { RoutingFormResponseRepository } from "@calcom/features/routing-forms/repositories/RoutingFormResponseRepository"; - +import { beforeEach, describe, expect, it, vi } from "vitest"; import { findTeamMembersMatchingAttributeLogic } from "./findTeamMembersMatchingAttributeLogic"; import { handleResponse } from "./handleResponse"; @@ -186,6 +183,7 @@ describe("handleResponse", () => { form: mockForm, formResponseInDb: dbFormResponse, chosenRouteAction: null, + fallbackAction: null, }); expect(result.formResponse).toEqual(dbFormResponse); expect(result.queuedFormResponse).toBeNull(); @@ -217,6 +215,7 @@ describe("handleResponse", () => { formId: mockForm.id, response: mockResponse, chosenRouteId: null, + fallbackAction: null, }); expect(mockRoutingFormResponseRepository.recordFormResponse).not.toHaveBeenCalled(); expect(onSubmissionOfFormResponse).not.toHaveBeenCalled(); @@ -344,7 +343,6 @@ describe("handleResponse", () => { }); describe("Fallback action", () => { - // Mock attributesQueryValue with actual rules const mockAttributesQueryValueWithRules = { type: "group", children1: { @@ -613,6 +611,117 @@ describe("handleResponse", () => { expect(result.fallbackAction).toEqual(fallbackAction); }); + it("should pass fallbackAction to onSubmissionOfFormResponse when fallback is triggered", async () => { + const fallbackAction = { + type: "externalRedirectUrl" as const, + value: "https://example.com/fallback", + }; + const chosenRoute = { + id: "route1", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + queryValue: { type: "group", children1: {} } as any, + action: { + type: "eventTypeRedirectUrl" as const, + value: "team/30min", + }, + attributeRoutingConfig: null, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + attributesQueryValue: mockAttributesQueryValueWithRules as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fallbackAttributesQueryValue: { type: "group", children1: {} } as any, + fallbackAction, + isFallback: false, + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const formWithRoute: TargetRoutingFormForResponse = { ...mockForm, routes: [chosenRoute as any] }; + + const dbFormResponse = { + id: 1, + uuid: "d8b4b7d2-3f45-4f67-9aa1-98c4b49cf283", + formId: mockForm.id, + response: mockResponse, + chosenRouteId: "route1", + formFillerId: "user1", + routedToBookingUid: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + vi.mocked(mockRoutingFormResponseRepository.recordFormResponse).mockResolvedValue(dbFormResponse); + + vi.mocked(findTeamMembersMatchingAttributeLogic).mockResolvedValue({ + teamMembersMatchingAttributeLogic: [], + checkedFallback: true, + fallbackAttributeLogicBuildingWarnings: [], + mainAttributeLogicBuildingWarnings: [], + timeTaken: {}, + }); + + await handleResponse({ + response: mockResponse, + form: formWithRoute, + formFillerId: "user1", + chosenRouteId: "route1", + isPreview: false, + identifierKeyedResponse: { + name: "John Doe", + email: "john.doe@example.com", + }, + }); + + expect(onSubmissionOfFormResponse).toHaveBeenCalledWith( + expect.objectContaining({ + fallbackAction, + }) + ); + }); + + it("should not call onSubmissionOfFormResponse in preview mode (so no fallback webhook fires)", async () => { + const fallbackAction = { + type: "externalRedirectUrl" as const, + value: "https://example.com/fallback", + }; + const chosenRoute = { + id: "route1", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + queryValue: { type: "group", children1: {} } as any, + action: { + type: "eventTypeRedirectUrl" as const, + value: "team/30min", + }, + attributeRoutingConfig: null, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + attributesQueryValue: mockAttributesQueryValueWithRules as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fallbackAttributesQueryValue: { type: "group", children1: {} } as any, + fallbackAction, + isFallback: false, + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const formWithRoute: TargetRoutingFormForResponse = { ...mockForm, routes: [chosenRoute as any] }; + + vi.mocked(findTeamMembersMatchingAttributeLogic).mockResolvedValue({ + teamMembersMatchingAttributeLogic: [], + checkedFallback: true, + fallbackAttributeLogicBuildingWarnings: [], + mainAttributeLogicBuildingWarnings: [], + timeTaken: {}, + }); + + await handleResponse({ + response: mockResponse, + form: formWithRoute, + formFillerId: "user1", + chosenRouteId: "route1", + isPreview: true, + identifierKeyedResponse: { + name: "John Doe", + email: "john.doe@example.com", + }, + }); + + expect(onSubmissionOfFormResponse).not.toHaveBeenCalled(); + }); + it("should return null fallbackAction when no team members found but CRM contact owner exists", async () => { const fallbackAction = { type: "externalRedirectUrl" as const, diff --git a/packages/features/routing-forms/lib/handleResponse.ts b/packages/features/routing-forms/lib/handleResponse.ts index 68c9eb47d2..335181f7de 100644 --- a/packages/features/routing-forms/lib/handleResponse.ts +++ b/packages/features/routing-forms/lib/handleResponse.ts @@ -5,6 +5,7 @@ import { } from "@calcom/app-store/routing-forms/lib/formSubmissionUtils"; import isRouter from "@calcom/app-store/routing-forms/lib/isRouter"; import { RouteActionType } from "@calcom/app-store/routing-forms/zod"; +import { RoutingFormResponseRepository } from "@calcom/features/routing-forms/repositories/RoutingFormResponseRepository"; import type { RoutingFormTraceService } from "@calcom/features/routing-trace/domains/RoutingFormTraceService"; import type { RoutingTraceService } from "@calcom/features/routing-trace/services/RoutingTraceService"; import { emailSchema } from "@calcom/lib/emailSchema"; @@ -12,7 +13,6 @@ import { HttpError } from "@calcom/lib/http-error"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import { withReporting } from "@calcom/lib/sentryWrapper"; -import { RoutingFormResponseRepository } from "@calcom/features/routing-forms/repositories/RoutingFormResponseRepository"; import { prisma } from "@calcom/prisma"; import { Prisma } from "@calcom/prisma/client"; import { z } from "zod"; @@ -136,14 +136,9 @@ const _handleResponse = async ({ crmRecordId = contactOwnerQuery?.recordId ?? null; })(), (async () => { - // Determine when to use fallbackAttributesQueryValue: - // 1. If fallbackAction is EventTypeRedirectUrl → use it (attribute routing applies to event redirect) - // 2. If fallbackAction is not set → use it (backwards compatibility) - // 3. If fallbackAction is CustomPageMessage or ExternalRedirectUrl → don't use it (attribute routing doesn't apply) const hasFallbackAction = "fallbackAction" in chosenRoute && chosenRoute.fallbackAction; const shouldUseFallbackAttributesQuery = - !hasFallbackAction || - chosenRoute.fallbackAction?.type === RouteActionType.EventTypeRedirectUrl; + !hasFallbackAction || chosenRoute.fallbackAction?.type === RouteActionType.EventTypeRedirectUrl; const teamMembersMatchingAttributeLogicWithResult = formTeamId && formOrgId ? await findTeamMembersMatchingAttributeLogic( @@ -189,6 +184,38 @@ const _handleResponse = async ({ } else { // It currently happens for a Router route. Such a route id isn't present in the form.routes } + const getFallbackAction = () => { + if (!chosenRoute || !("fallbackAction" in chosenRoute)) { + return null; + } + + const hasCrmContactOwner = crmContactOwnerEmail !== null; + if (hasCrmContactOwner) { + return null; + } + + if (checkedFallback) { + return chosenRoute.fallbackAction; + } + + const attributesQueryValue = + "attributesQueryValue" in chosenRoute ? chosenRoute.attributesQueryValue : null; + const hasAttributeRoutingConfigured = + attributesQueryValue && + typeof attributesQueryValue === "object" && + "children1" in attributesQueryValue && + attributesQueryValue.children1 && + Object.keys(attributesQueryValue.children1 as Record).length > 0; + + if (hasAttributeRoutingConfigured && teamMemberIdsMatchingAttributeLogic === null) { + return chosenRoute.fallbackAction; + } + + return null; + }; + + const fallbackAction = getFallbackAction(); + let dbFormResponse, queuedFormResponse; if (!isPreview) { const formResponseRepo = new RoutingFormResponseRepository(prisma); @@ -197,6 +224,7 @@ const _handleResponse = async ({ formId: form.id, response, chosenRouteId, + fallbackAction: fallbackAction as Prisma.InputJsonValue | null, }); dbFormResponse = null; } else { @@ -211,6 +239,7 @@ const _handleResponse = async ({ form: serializableFormWithFields, formResponseInDb: dbFormResponse, chosenRouteAction: chosenRoute ? ("action" in chosenRoute ? chosenRoute.action : null) : null, + fallbackAction, }); } } else { @@ -233,36 +262,6 @@ const _handleResponse = async ({ }; } } - const getFallbackAction = () => { - if (!chosenRoute || !("fallbackAction" in chosenRoute)) { - return null; - } - - const hasCrmContactOwner = crmContactOwnerEmail !== null; - if (hasCrmContactOwner) { - return null; - } - - if (checkedFallback) { - return chosenRoute.fallbackAction; - } - - // Handle case where attribute routing was configured but couldn't run (e.g., missing orgId) - const attributesQueryValue = - "attributesQueryValue" in chosenRoute ? chosenRoute.attributesQueryValue : null; - const hasAttributeRoutingConfigured = - attributesQueryValue && - typeof attributesQueryValue === "object" && - "children1" in attributesQueryValue && - attributesQueryValue.children1 && - Object.keys(attributesQueryValue.children1 as Record).length > 0; - - if (hasAttributeRoutingConfigured && teamMemberIdsMatchingAttributeLogic === null) { - return chosenRoute.fallbackAction; - } - - return null; - }; return { isPreview: !!isPreview, @@ -280,7 +279,7 @@ const _handleResponse = async ({ : null, checkedFallback, timeTaken, - fallbackAction: getFallbackAction(), + fallbackAction, }; } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError) { diff --git a/packages/features/routing-forms/repositories/RoutingFormResponseRepository.ts b/packages/features/routing-forms/repositories/RoutingFormResponseRepository.ts index fd8a3de9db..92c0e76842 100644 --- a/packages/features/routing-forms/repositories/RoutingFormResponseRepository.ts +++ b/packages/features/routing-forms/repositories/RoutingFormResponseRepository.ts @@ -39,9 +39,15 @@ export class RoutingFormResponseRepository { }); } - async recordQueuedFormResponse(input: RecordFormResponseInput) { + async recordQueuedFormResponse( + input: RecordFormResponseInput & { fallbackAction?: Prisma.InputJsonValue | null } + ) { + const { fallbackAction, ...rest } = input; return await this.prismaClient.app_RoutingForms_QueuedFormResponse.create({ - data: this.generateCreateFormResponseData(input), + data: { + ...this.generateCreateFormResponseData(rest), + ...(fallbackAction ? { fallbackAction } : {}), + }, }); } @@ -91,6 +97,7 @@ export class RoutingFormResponseRepository { formId: true, response: true, chosenRouteId: true, + fallbackAction: true, createdAt: true, updatedAt: true, actualResponseId: true, diff --git a/packages/features/webhooks/lib/constants.ts b/packages/features/webhooks/lib/constants.ts index f250e2db66..e8dd3b1105 100644 --- a/packages/features/webhooks/lib/constants.ts +++ b/packages/features/webhooks/lib/constants.ts @@ -1,5 +1,4 @@ import { WebhookTriggerEvents } from "@calcom/prisma/enums"; - import { WebhookVersion } from "./interface/IWebhookRepository"; // this is exported as we can't use `WebhookTriggerEvents` in the frontend straight-off @@ -67,6 +66,7 @@ export const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP = { "routing-forms": [ WebhookTriggerEvents.FORM_SUBMITTED, WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT, + WebhookTriggerEvents.ROUTING_FORM_FALLBACK_HIT, ] as const, }; diff --git a/packages/features/webhooks/lib/factory/versioned/PayloadBuilderFactory.ts b/packages/features/webhooks/lib/factory/versioned/PayloadBuilderFactory.ts index 98c4462b8b..3d8e535ed8 100644 --- a/packages/features/webhooks/lib/factory/versioned/PayloadBuilderFactory.ts +++ b/packages/features/webhooks/lib/factory/versioned/PayloadBuilderFactory.ts @@ -91,6 +91,7 @@ const TRIGGER_TO_BUILDER_CATEGORY: Record // Form events [WebhookTriggerEvents.FORM_SUBMITTED]: "form", [WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT]: "form", + [WebhookTriggerEvents.ROUTING_FORM_FALLBACK_HIT]: "form", // OOO events [WebhookTriggerEvents.OOO_CREATED]: "ooo", @@ -133,7 +134,8 @@ export type DelegationTriggerEvents = typeof WebhookTriggerEvents.DELEGATION_CRE export type FormTriggerEvents = | typeof WebhookTriggerEvents.FORM_SUBMITTED - | typeof WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT; + | typeof WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT + | typeof WebhookTriggerEvents.ROUTING_FORM_FALLBACK_HIT; export type OOOTriggerEvents = typeof WebhookTriggerEvents.OOO_CREATED; diff --git a/packages/i18n/locales/en/common.json b/packages/i18n/locales/en/common.json index a8b16eb8df..c197f19579 100644 --- a/packages/i18n/locales/en/common.json +++ b/packages/i18n/locales/en/common.json @@ -4197,6 +4197,7 @@ "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", + "routing_form_fallback_hit_trigger": "When routing form fallback route is triggered", "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", diff --git a/packages/prisma/migrations/20260218000000_add_routing_form_fallback_hit_webhook_trigger/migration.sql b/packages/prisma/migrations/20260218000000_add_routing_form_fallback_hit_webhook_trigger/migration.sql new file mode 100644 index 0000000000..1781f9326c --- /dev/null +++ b/packages/prisma/migrations/20260218000000_add_routing_form_fallback_hit_webhook_trigger/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'ROUTING_FORM_FALLBACK_HIT'; diff --git a/packages/prisma/migrations/20260219000000_add_fallback_action_to_queued_form_response/migration.sql b/packages/prisma/migrations/20260219000000_add_fallback_action_to_queued_form_response/migration.sql new file mode 100644 index 0000000000..c6226666c1 --- /dev/null +++ b/packages/prisma/migrations/20260219000000_add_fallback_action_to_queued_form_response/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "App_RoutingForms_QueuedFormResponse" ADD COLUMN "fallbackAction" JSONB; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 38ac05a73c..8ca80f786b 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -1155,6 +1155,7 @@ enum WebhookTriggerEvents { AFTER_HOSTS_CAL_VIDEO_NO_SHOW AFTER_GUESTS_CAL_VIDEO_NO_SHOW FORM_SUBMITTED_NO_EVENT + ROUTING_FORM_FALLBACK_HIT DELEGATION_CREDENTIAL_ERROR WRONG_ASSIGNMENT_REPORT } @@ -1375,6 +1376,7 @@ model App_RoutingForms_QueuedFormResponse { formId String response Json chosenRouteId String? + fallbackAction Json? createdAt DateTime @default(now()) updatedAt DateTime? @updatedAt actualResponseId Int? @unique