feat: add webhook trigger when routing form fallback route is hit (#27588)

* feat: add no users found fallback action for routing forms

- Add NoUsersFoundFallbackActionType enum with CustomPageMessage and ExternalRedirectUrl options
- Add noUsersFoundFallbackAction field to zodNonRouterRoute schema
- Add NoUsersFoundFallback UI component in RouteBuilder for configuring fallback action
- Update handleResponse to return noUsersFoundFallbackAction when no team members match
- Add i18n translation keys for new UI strings

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* refactor: change fallback to use same action options as main route

- Replace noUsersFoundFallbackAction with fallbackAction that has same structure as main action
- Add action dropdown and input fields to fallback attributes query builder section
- Support Event redirect, External URL, and Custom page options in fallback
- Remove separate NoUsersFoundFallback toggle component
- Update handleResponse to return fallbackAction

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: ensure fallbackAction type is always defined in onChange handlers

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: add guards for route.fallbackAction in onChange handlers

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: only return fallbackAction when no users are found

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* refactor: restructure fallback section to mirror main route structure

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: ensure backwards compatibility for existing routes with fallbackAttributesQueryValue

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: initialize fallbackAction with main event type for backwards compatibility

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: include fallbackAction in getRoutesToSave to persist changes

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: use fallbackAction in getRoutedUrl when no team members found

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: use i18n for fallback label and add tests for fallback action

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: do not trigger fallback action when CRM contact owner is found

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: prioritize fallbackAction over fallbackAttributesQueryValue

When a route has a fallbackAction configured, skip the
fallbackAttributesQueryValue to ensure the fallbackAction is triggered
when no team members are found. This maintains backwards compatibility
by only using fallbackAttributesQueryValue when fallbackAction is not set.

Also treats teamMemberIdsMatchingAttributeLogic being null (routing couldn't
run, e.g., missing orgId) the same as an empty array for the purpose of
triggering the fallbackAction.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: extract RouteActionSelector shared component for action selection UI

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: add i18n string for default custom page message

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* feat: add webhook trigger when routing form fallback route is hit

- Add ROUTING_FORM_FALLBACK_HIT to WebhookTriggerEvents enum in Prisma schema
- Add new trigger to routing-forms webhook triggers in constants
- Add translation string for the new webhook trigger
- Implement triggerFallbackWebhook function in formSubmissionUtils.ts
- Call webhook trigger from handleResponse.ts when fallback action is used

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: remove webhook object from error log to avoid exposing secrets

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* revert: remove webhook trigger changes (to be moved to separate PR)

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* feat: add webhook trigger when routing form fallback route is hit

- Add ROUTING_FORM_FALLBACK_HIT to WebhookTriggerEvents enum in Prisma schema
- Add new trigger to routing-forms webhook triggers in constants
- Add translation string for the new webhook trigger
- Implement triggerFallbackWebhook function in formSubmissionUtils.ts
- Call webhook trigger from handleResponse.ts when fallback action is used

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: remove webhook object from error log to avoid exposing secrets

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: add ROUTING_FORM_FALLBACK_HIT to PayloadBuilderFactory trigger mapping

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* style: apply biome formatting fixes

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: restore removed comments in RouteBuilder

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* chore: add prisma migration for ROUTING_FORM_FALLBACK_HIT enum value

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: add ROUTING_FORM_FALLBACK_HIT to FormTriggerEvents type union

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* test: add test coverage for triggerFallbackWebhook function

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* refactor: consolidate fallback webhook firing with form submission webhooks

Move ROUTING_FORM_FALLBACK_HIT webhooks into the same Promise.all() batch
as FORM_SUBMITTED / FORM_SUBMITTED_NO_EVENT, eliminating redundant
getWebhooks/getWebhookTargetEntity/getOrgIdFromMemberOrTeamId calls.

- Add fallbackAction parameter to _onFormSubmission and onSubmissionOfFormResponse
- Remove triggerFallbackWebhook function and all its usages/imports
- Move getFallbackAction() earlier in handleResponse.ts and pass result through
- Update all tests to verify batched webhook behavior

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Add UI option for trigger

* feat: persist fallbackAction on queued form responses

When a routing form response is queued, the fallbackAction determined
during routing is now stored on the QueuedFormResponse record. When
the queued response is later converted to a real FormResponse, the
stored fallbackAction is passed to onSubmissionOfFormResponse so the
ROUTING_FORM_FALLBACK_HIT webhook fires correctly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: add coverage for fallbackAction in queued form response flow

Verify that fallbackAction is persisted when queuing and passed through
to onSubmissionOfFormResponse when the queued response is converted.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Joe Au-Yeung
2026-02-23 16:44:41 -05:00
committed by GitHub
co-authored by Claude Opus 4.6 Devin AI
parent 8238d4ffcd
commit 48cbb94af2
15 changed files with 510 additions and 67 deletions
@@ -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<ReturnType<typeof getSerializableForm>>);
vi.mocked(getResponseToStore).mockReturnValue({} as ReturnType<typeof getResponseToStore>);
vi.mocked(onSubmissionOfFormResponse).mockResolvedValue(undefined as unknown as Awaited<
ReturnType<typeof onSubmissionOfFormResponse>
>);
vi.mocked(mockRoutingFormResponseRepository.recordFormResponse).mockResolvedValue({
id: 1,
response: {},
} as Awaited<ReturnType<typeof mockRoutingFormResponseRepository.recordFormResponse>>);
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({
@@ -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 {
@@ -73,6 +73,7 @@ const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP_V2: Record<string, WebhookTriggerEve
"routing-forms": [
{ value: WebhookTriggerEvents.FORM_SUBMITTED, label: "form_submitted" },
{ value: WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT, label: "form_submitted_no_event" },
{ value: WebhookTriggerEvents.ROUTING_FORM_FALLBACK_HIT, label: "routing_form_fallback_hit_trigger" },
],
} as const;
@@ -397,3 +397,215 @@ describe("_onFormSubmission", () => {
});
});
});
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();
});
});
@@ -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<string, FormResponse[keyof FormResponse]["value"]>),
...Object.entries(fieldResponsesByIdentifier).reduce(
(acc, [key, value]) => {
const normalizedKey = normalizeIdentifierForHandlebars(key);
acc[normalizedKey] = value.value;
return acc;
},
{} as Record<string, FormResponse[keyof FormResponse]["value"]>
),
},
}).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
);
};
+3 -5
View File
@@ -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";
@@ -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,
@@ -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<string, unknown>).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<string, unknown>).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) {
@@ -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,
+1 -1
View File
@@ -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,
};
@@ -91,6 +91,7 @@ const TRIGGER_TO_BUILDER_CATEGORY: Record<WebhookTriggerEvents, BuilderCategory>
// 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;
+1
View File
@@ -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",
@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'ROUTING_FORM_FALLBACK_HIT';
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "App_RoutingForms_QueuedFormResponse" ADD COLUMN "fallbackAction" JSONB;
+2
View File
@@ -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