* 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>
785 lines
27 KiB
TypeScript
785 lines
27 KiB
TypeScript
import "@calcom/lib/__mocks__/logger";
|
|
import { prisma } from "@calcom/prisma/__mocks__/prisma";
|
|
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";
|
|
|
|
vi.mock("./findTeamMembersMatchingAttributeLogic", () => ({
|
|
findTeamMembersMatchingAttributeLogic: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@calcom/features/routing-forms/repositories/RoutingFormResponseRepository");
|
|
|
|
const mockRoutingFormResponseRepository = {
|
|
recordQueuedFormResponse: vi.fn(),
|
|
recordFormResponse: vi.fn(),
|
|
};
|
|
|
|
vi.mock("@calcom/app-store/routing-forms/lib/crmRouting/routerGetCrmContactOwnerEmail", () => ({
|
|
default: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@calcom/app-store/routing-forms/lib/formSubmissionUtils", () => ({
|
|
onSubmissionOfFormResponse: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@calcom/app-store/routing-forms/lib/isRouter", () => ({
|
|
default: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@calcom/lib/sentryWrapper", () => ({
|
|
withReporting: (fn: unknown) => fn,
|
|
}));
|
|
|
|
vi.mock("@calcom/prisma", () => ({
|
|
prisma,
|
|
}));
|
|
|
|
const mockForm: TargetRoutingFormForResponse = {
|
|
id: "form-id",
|
|
name: "Test Form",
|
|
fields: [
|
|
{
|
|
id: "name",
|
|
label: "Name",
|
|
type: "text" as const,
|
|
required: true,
|
|
identifier: "name",
|
|
placeholder: undefined,
|
|
selectText: undefined,
|
|
deleted: false,
|
|
},
|
|
{
|
|
id: "email",
|
|
label: "Email",
|
|
type: "email" as const,
|
|
required: true,
|
|
identifier: "email",
|
|
placeholder: undefined,
|
|
selectText: undefined,
|
|
deleted: false,
|
|
},
|
|
{
|
|
id: "guests",
|
|
label: "Guests",
|
|
type: "text" as const,
|
|
required: false,
|
|
identifier: "guests",
|
|
placeholder: undefined,
|
|
selectText: undefined,
|
|
deleted: false,
|
|
},
|
|
],
|
|
routes: [],
|
|
userId: 1,
|
|
teamId: 1,
|
|
user: {
|
|
id: 1,
|
|
email: "test@example.com",
|
|
timeFormat: null,
|
|
locale: null,
|
|
},
|
|
team: {
|
|
parentId: 2,
|
|
},
|
|
createdAt: "2023-01-01T00:00:00.000Z",
|
|
updatedAt: "2023-01-01T00:00:00.000Z",
|
|
description: null,
|
|
disabled: false,
|
|
settings: {
|
|
emailOwnerOnSubmission: false,
|
|
},
|
|
connectedForms: [],
|
|
routers: [],
|
|
teamMembers: [],
|
|
position: 0,
|
|
updatedById: null,
|
|
};
|
|
|
|
const mockResponse: Record<
|
|
string,
|
|
{
|
|
value: (string | number | string[]) & (string | number | string[] | undefined);
|
|
label: string;
|
|
identifier?: string | undefined;
|
|
}
|
|
> = {
|
|
name: { value: "John Doe", label: "Name" },
|
|
email: { value: "john.doe@example.com", label: "Email" },
|
|
};
|
|
|
|
describe("handleResponse", () => {
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
vi.mocked(RoutingFormResponseRepository).mockImplementation(function () {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
return mockRoutingFormResponseRepository as any;
|
|
});
|
|
});
|
|
|
|
it("should throw an Error for missing required fields", async () => {
|
|
await expect(
|
|
handleResponse({
|
|
response: { email: { value: "test@test.com", label: "Email" } }, // Name is missing
|
|
form: mockForm,
|
|
identifierKeyedResponse: null,
|
|
formFillerId: "user1",
|
|
chosenRouteId: null,
|
|
isPreview: false,
|
|
})
|
|
).rejects.toThrow(/Missing required fields Name/);
|
|
});
|
|
|
|
it("should throw an Error for invalid email", async () => {
|
|
await expect(
|
|
handleResponse({
|
|
response: {
|
|
name: { value: "John Doe", label: "Name" },
|
|
email: { value: "invalid-email", label: "Email" },
|
|
},
|
|
form: mockForm,
|
|
identifierKeyedResponse: null,
|
|
formFillerId: "user1",
|
|
chosenRouteId: null,
|
|
isPreview: false,
|
|
})
|
|
).rejects.toThrow(/Invalid value for fields 'Email' with value 'invalid-email' should be valid email/);
|
|
});
|
|
|
|
it("should record form response when not in preview and not queued", async () => {
|
|
const dbFormResponse = {
|
|
id: 1,
|
|
uuid: "d8b4b7d2-3f45-4f67-9aa1-98c4b49cf283",
|
|
formId: mockForm.id,
|
|
response: mockResponse,
|
|
chosenRouteId: null,
|
|
formFillerId: "user1",
|
|
routedToBookingUid: null,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
};
|
|
vi.mocked(mockRoutingFormResponseRepository.recordFormResponse).mockResolvedValue(dbFormResponse);
|
|
|
|
const result = await handleResponse({
|
|
response: mockResponse,
|
|
form: mockForm,
|
|
identifierKeyedResponse: null,
|
|
formFillerId: "user1",
|
|
chosenRouteId: null,
|
|
isPreview: false,
|
|
});
|
|
|
|
expect(mockRoutingFormResponseRepository.recordFormResponse).toHaveBeenCalledWith({
|
|
formId: mockForm.id,
|
|
response: mockResponse,
|
|
chosenRouteId: null,
|
|
});
|
|
expect(onSubmissionOfFormResponse).toHaveBeenCalledWith({
|
|
form: mockForm,
|
|
formResponseInDb: dbFormResponse,
|
|
chosenRouteAction: null,
|
|
fallbackAction: null,
|
|
});
|
|
expect(result.formResponse).toEqual(dbFormResponse);
|
|
expect(result.queuedFormResponse).toBeNull();
|
|
});
|
|
|
|
it("should queue form response when queueFormResponse is true", async () => {
|
|
const queuedResponse = {
|
|
id: "queued-id",
|
|
formId: mockForm.id,
|
|
response: mockResponse,
|
|
chosenRouteId: null,
|
|
actualResponseId: null,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
};
|
|
vi.mocked(mockRoutingFormResponseRepository.recordQueuedFormResponse).mockResolvedValue(queuedResponse);
|
|
|
|
const result = await handleResponse({
|
|
response: mockResponse,
|
|
form: mockForm,
|
|
identifierKeyedResponse: null,
|
|
formFillerId: "user1",
|
|
chosenRouteId: null,
|
|
isPreview: false,
|
|
queueFormResponse: true,
|
|
});
|
|
|
|
expect(mockRoutingFormResponseRepository.recordQueuedFormResponse).toHaveBeenCalledWith({
|
|
formId: mockForm.id,
|
|
response: mockResponse,
|
|
chosenRouteId: null,
|
|
fallbackAction: null,
|
|
});
|
|
expect(mockRoutingFormResponseRepository.recordFormResponse).not.toHaveBeenCalled();
|
|
expect(onSubmissionOfFormResponse).not.toHaveBeenCalled();
|
|
expect(result.queuedFormResponse).toEqual(queuedResponse);
|
|
expect(result.formResponse).toBeNull();
|
|
});
|
|
|
|
describe("Preview mode", () => {
|
|
it("should send formResponse with id=0 and quueueFormResponse isn't true", async () => {
|
|
const result = await handleResponse({
|
|
response: mockResponse,
|
|
form: mockForm,
|
|
identifierKeyedResponse: null,
|
|
formFillerId: "user1",
|
|
chosenRouteId: null,
|
|
isPreview: true,
|
|
});
|
|
|
|
expect(mockRoutingFormResponseRepository.recordFormResponse).not.toHaveBeenCalled();
|
|
expect(onSubmissionOfFormResponse).not.toHaveBeenCalled();
|
|
expect(result.isPreview).toBe(true);
|
|
expect(result.formResponse).toBeDefined();
|
|
expect(result.formResponse?.id).toBe(0);
|
|
});
|
|
|
|
it("should send queuedFormResponse with id=00000000-0000-0000-0000-000000000000 when queueFormResponse is true", async () => {
|
|
const result = await handleResponse({
|
|
response: mockResponse,
|
|
form: mockForm,
|
|
identifierKeyedResponse: null,
|
|
formFillerId: "user1",
|
|
chosenRouteId: null,
|
|
isPreview: true,
|
|
queueFormResponse: true,
|
|
});
|
|
|
|
expect(mockRoutingFormResponseRepository.recordQueuedFormResponse).not.toHaveBeenCalled();
|
|
expect(result.isPreview).toBe(true);
|
|
expect(result.queuedFormResponse).toBeDefined();
|
|
expect(result.queuedFormResponse?.id).toBe("00000000-0000-0000-0000-000000000000");
|
|
expect(result.formResponse).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
it("should handle chosen route and call routing logic", async () => {
|
|
const chosenRoute = {
|
|
id: "route1",
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
queryValue: { type: "group", children1: {} } as any,
|
|
action: {
|
|
type: "customPageMessage" as const,
|
|
value: "Thank you for your submission!",
|
|
},
|
|
attributeRoutingConfig: null,
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
attributesQueryValue: { type: "group", children1: {} } as any,
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
fallbackAttributesQueryValue: { type: "group", children1: {} } as any,
|
|
isFallback: false,
|
|
};
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const formWithRoute: TargetRoutingFormForResponse = { ...mockForm, routes: [chosenRoute as any] };
|
|
|
|
vi.mocked(routerGetCrmContactOwnerEmail).mockResolvedValue({
|
|
email: "owner@example.com",
|
|
recordType: "contact",
|
|
crmAppSlug: "hubspot",
|
|
recordId: "123",
|
|
});
|
|
vi.mocked(findTeamMembersMatchingAttributeLogic).mockResolvedValue({
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
teamMembersMatchingAttributeLogic: [{ userId: 123, result: "MATCH" as any }],
|
|
checkedFallback: false,
|
|
fallbackAttributeLogicBuildingWarnings: [],
|
|
mainAttributeLogicBuildingWarnings: [],
|
|
timeTaken: {
|
|
ttGetAttributesForLogic: 50,
|
|
},
|
|
});
|
|
|
|
const result = await handleResponse({
|
|
response: mockResponse,
|
|
form: formWithRoute,
|
|
formFillerId: "user1",
|
|
chosenRouteId: "route1",
|
|
isPreview: false,
|
|
identifierKeyedResponse: {
|
|
name: "John Doe",
|
|
email: "john.doe@example.com",
|
|
},
|
|
fetchCrm: true,
|
|
});
|
|
|
|
expect(routerGetCrmContactOwnerEmail).toHaveBeenCalled();
|
|
expect(findTeamMembersMatchingAttributeLogic).toHaveBeenCalled();
|
|
expect(result.crmContactOwnerEmail).toBe("owner@example.com");
|
|
expect(result.teamMembersMatchingAttributeLogic).toEqual([123]);
|
|
});
|
|
|
|
it("should throw error if chosen route is a router", async () => {
|
|
const chosenRoute = {
|
|
id: "route1",
|
|
name: "A router",
|
|
description: "Router description",
|
|
isRouter: true as const,
|
|
routes: [],
|
|
};
|
|
vi.mocked(isRouter).mockReturnValue(true);
|
|
const formWithRoute: TargetRoutingFormForResponse = {
|
|
...mockForm,
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
routes: [chosenRoute as any],
|
|
};
|
|
|
|
await expect(
|
|
handleResponse({
|
|
response: mockResponse,
|
|
form: formWithRoute,
|
|
identifierKeyedResponse: null,
|
|
formFillerId: "user1",
|
|
chosenRouteId: "route1",
|
|
isPreview: false,
|
|
})
|
|
).rejects.toThrow(/Chosen route is a router/);
|
|
});
|
|
|
|
describe("Fallback action", () => {
|
|
const mockAttributesQueryValueWithRules = {
|
|
type: "group",
|
|
children1: {
|
|
"rule-1": {
|
|
type: "rule",
|
|
properties: {
|
|
field: "attr-1",
|
|
operator: "select_equals",
|
|
value: ["option-1"],
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
it("should return fallbackAction when no team members match attribute logic", 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: [], // Empty array = no users found
|
|
checkedFallback: true, // Fallback was checked and triggered
|
|
fallbackAttributeLogicBuildingWarnings: [],
|
|
mainAttributeLogicBuildingWarnings: [],
|
|
timeTaken: {},
|
|
});
|
|
|
|
const result = await handleResponse({
|
|
response: mockResponse,
|
|
form: formWithRoute,
|
|
formFillerId: "user1",
|
|
chosenRouteId: "route1",
|
|
isPreview: true,
|
|
identifierKeyedResponse: {
|
|
name: "John Doe",
|
|
email: "john.doe@example.com",
|
|
},
|
|
});
|
|
|
|
expect(result.teamMembersMatchingAttributeLogic).toEqual([]);
|
|
expect(result.fallbackAction).toEqual(fallbackAction);
|
|
|
|
// Verify that fallbackAttributesQueryValue is NOT passed when fallbackAction is configured
|
|
expect(findTeamMembersMatchingAttributeLogic).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
fallbackAttributesQueryValue: undefined,
|
|
}),
|
|
expect.anything()
|
|
);
|
|
});
|
|
|
|
it("should return null fallbackAction when team members are found", 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({
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
teamMembersMatchingAttributeLogic: [{ userId: 123, result: "MATCH" as any }],
|
|
checkedFallback: false,
|
|
fallbackAttributeLogicBuildingWarnings: [],
|
|
mainAttributeLogicBuildingWarnings: [],
|
|
timeTaken: {},
|
|
});
|
|
|
|
const result = await handleResponse({
|
|
response: mockResponse,
|
|
form: formWithRoute,
|
|
formFillerId: "user1",
|
|
chosenRouteId: "route1",
|
|
isPreview: true,
|
|
identifierKeyedResponse: {
|
|
name: "John Doe",
|
|
email: "john.doe@example.com",
|
|
},
|
|
});
|
|
|
|
expect(result.teamMembersMatchingAttributeLogic).toEqual([123]);
|
|
expect(result.fallbackAction).toBeNull();
|
|
});
|
|
|
|
it("should return null fallbackAction when route has no fallbackAction configured", async () => {
|
|
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: { type: "group", children1: {} } as any,
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
fallbackAttributesQueryValue: { type: "group", children1: {} } as any,
|
|
// No fallbackAction configured
|
|
isFallback: false,
|
|
};
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const formWithRoute: TargetRoutingFormForResponse = { ...mockForm, routes: [chosenRoute as any] };
|
|
|
|
vi.mocked(findTeamMembersMatchingAttributeLogic).mockResolvedValue({
|
|
teamMembersMatchingAttributeLogic: [], // Empty array = no users found
|
|
checkedFallback: false,
|
|
fallbackAttributeLogicBuildingWarnings: [],
|
|
mainAttributeLogicBuildingWarnings: [],
|
|
timeTaken: {},
|
|
});
|
|
|
|
const result = await handleResponse({
|
|
response: mockResponse,
|
|
form: formWithRoute,
|
|
formFillerId: "user1",
|
|
chosenRouteId: "route1",
|
|
isPreview: true,
|
|
identifierKeyedResponse: {
|
|
name: "John Doe",
|
|
email: "john.doe@example.com",
|
|
},
|
|
});
|
|
|
|
expect(result.teamMembersMatchingAttributeLogic).toEqual([]);
|
|
expect(result.fallbackAction).toBeNull();
|
|
|
|
// Verify that fallbackAttributesQueryValue IS passed when fallbackAction is NOT configured (backwards compatibility)
|
|
expect(findTeamMembersMatchingAttributeLogic).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
fallbackAttributesQueryValue: { type: "group", children1: {} },
|
|
}),
|
|
expect.anything()
|
|
);
|
|
});
|
|
|
|
it("should return null fallbackAction when teamMembersMatchingAttributeLogic is null (non-team form)", 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: { type: "group", children1: {} } as any,
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
fallbackAttributesQueryValue: { type: "group", children1: {} } as any,
|
|
fallbackAction,
|
|
isFallback: false,
|
|
};
|
|
// Non-team form (no teamId or orgId)
|
|
const nonTeamForm: TargetRoutingFormForResponse = {
|
|
...mockForm,
|
|
teamId: null,
|
|
team: null,
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
routes: [chosenRoute as any],
|
|
};
|
|
|
|
const result = await handleResponse({
|
|
response: mockResponse,
|
|
form: nonTeamForm,
|
|
formFillerId: "user1",
|
|
chosenRouteId: "route1",
|
|
isPreview: true,
|
|
identifierKeyedResponse: {
|
|
name: "John Doe",
|
|
email: "john.doe@example.com",
|
|
},
|
|
});
|
|
|
|
// teamMembersMatchingAttributeLogic is null for non-team forms
|
|
expect(result.teamMembersMatchingAttributeLogic).toBeNull();
|
|
// fallbackAction should be null because attribute routing has no rules configured (empty children1)
|
|
expect(result.fallbackAction).toBeNull();
|
|
});
|
|
|
|
it("should return fallbackAction when team has no orgId but attribute routing is configured", 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,
|
|
// Route has attribute routing rules configured
|
|
// 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,
|
|
};
|
|
// Team form but team has no parentId (not part of an org)
|
|
const teamWithoutOrgForm: TargetRoutingFormForResponse = {
|
|
...mockForm,
|
|
teamId: 123,
|
|
team: { parentId: null }, // No org
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
routes: [chosenRoute as any],
|
|
};
|
|
|
|
const result = await handleResponse({
|
|
response: mockResponse,
|
|
form: teamWithoutOrgForm,
|
|
formFillerId: "user1",
|
|
chosenRouteId: "route1",
|
|
isPreview: true,
|
|
identifierKeyedResponse: {
|
|
name: "John Doe",
|
|
email: "john.doe@example.com",
|
|
},
|
|
});
|
|
|
|
// teamMembersMatchingAttributeLogic is null because orgId is missing (attribute routing couldn't run)
|
|
expect(result.teamMembersMatchingAttributeLogic).toBeNull();
|
|
// fallbackAction SHOULD be returned because attribute routing was configured but couldn't run
|
|
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,
|
|
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: [], // Empty array = no users found
|
|
checkedFallback: false,
|
|
fallbackAttributeLogicBuildingWarnings: [],
|
|
mainAttributeLogicBuildingWarnings: [],
|
|
timeTaken: {},
|
|
});
|
|
|
|
// Mock CRM contact owner found
|
|
vi.mocked(routerGetCrmContactOwnerEmail).mockResolvedValue({
|
|
email: "crm-owner@example.com",
|
|
recordType: "contact",
|
|
crmAppSlug: "hubspot",
|
|
recordId: "123",
|
|
});
|
|
|
|
const result = await handleResponse({
|
|
response: mockResponse,
|
|
form: formWithRoute,
|
|
formFillerId: "user1",
|
|
chosenRouteId: "route1",
|
|
isPreview: true,
|
|
fetchCrm: true,
|
|
identifierKeyedResponse: {
|
|
name: "John Doe",
|
|
email: "john.doe@example.com",
|
|
},
|
|
});
|
|
|
|
expect(result.teamMembersMatchingAttributeLogic).toEqual([]);
|
|
expect(result.crmContactOwnerEmail).toEqual("crm-owner@example.com");
|
|
// fallbackAction should be null because CRM contact owner was found
|
|
expect(result.fallbackAction).toBeNull();
|
|
});
|
|
});
|
|
});
|