Files
calendar/packages/features/routing-forms/lib/handleResponse.ts
T
Joe Au-YeungGitHubClaude Opus 4.5joe@cal.com <j.auyeung419@gmail.com>joe@cal.com <j.auyeung419@gmail.com>joe@cal.com <j.auyeung419@gmail.com>Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
f536100224 feat: Add RoutingFormTrace (#27316)
* Add DB schema

* Init zod schema

* Init RoutingTrace and PendingRoutingTrace repository interfaces

* Create PrismaPendingRoutingTraceRepository

* Init RoutingTraceService

* Create RoutingTraceService container

* User routing trace service in routing

* Create RoutingTraceRepository and PrismaRoutingTraceRepoistory

* Add findByFormResponseId and findByQueuedFormResponseId to PendingRoutingTraceRepository

* Update DI containers

* RoutingTraceService create process booking method

* Use pending routing trace rather than URL params

* Fix schema

* Fix writing assignment reason for routed booking

* Remove  from  service

* Refactor RoutingTraceService to not rely on async local storage

* Pass RoutingTraceService through routing call

* Add attribute-logic-evaluated to routing trace step

* Add routing trace to trpc endpoint

* Add CRM routing trace step

* Fix extracting routing trace to assignment reason

* Add back CRM params to prevent refetching

* test: Add unit tests for RoutingTraceService and repositories

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

* test: Add missing mock for RoutingTraceService in getRoutedUrl tests

Also fix pre-existing lint issues in the test file:
- Add explicit types to mockForm and mockSerializableForm variables
- Add explicit type to url parameter in mockContext
- Replace 'as any' with 'as unknown as InstanceType<typeof UserRepository>'

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

* Link pending form submission to routing trace

* Clean up

* Add lookup field assignment reasons

* Rename to PendingRoutingTrace

* Add migration file

* fix: Update RoutingTraceService tests to use assignmentReasonRepository mock

- Add getStepsCount() method back to RoutingTraceService
- Add queuedFormResponseId support to processForBooking method
- Update tests to use mockAssignmentReasonRepository instead of prisma mock
- Remove test for missing routingTraceRepository (all deps now required)

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

* fix: Remove PII (organizer email) from log payload in RoutingTraceService

Addresses Cubic AI review feedback with confidence 9/10.
Logging PII violates sensitive information logging rules.

Co-Authored-By: unknown <>

* Write field values at the time of routing

* Write attributes used to route

* fix: Add CHECK constraint to ensure at least one response ID is set in routing trace tables

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

* fix: Address PR review comments for routing trace feature

- Add checkedFallback to routing trace step data (Comment 11)
- Extract hardcoded domain/step strings to constants (Comment 13)
- Use @default(now()) for createdAt in PendingRoutingTrace and RoutingTrace (Comment 15)
- Add DEFAULT CURRENT_TIMESTAMP to migration for createdAt fields

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

* fix: Use UTC-safe timezone expression for createdAt defaults in routing trace tables

Addresses Cubic AI review feedback (confidence 9/10) to prevent timezone
issues by using dbgenerated("timezone('UTC', now())") instead of bare now()
for the createdAt fields in PendingRoutingTrace and RoutingTrace models.

Co-Authored-By: unknown <>

* fix: Align migration SQL with Prisma's expected timezone syntax

Use 'UTC'::text cast in timezone() function to match Prisma's generated SQL.

Co-Authored-By: unknown <>

* fix: Revert migration SQL to match Prisma schema timezone syntax

Remove ::text cast from timezone() function to match what Prisma generates
from the schema definition.

Co-Authored-By: unknown <>

* fix: Use explicit ::text cast in timezone() for PostgreSQL compatibility

PostgreSQL normalizes timezone('UTC', now()) to timezone('UTC'::text, now())
internally. Update both schema and migration to use the explicit cast to
ensure they match and pass the migration check.

Co-Authored-By: unknown <>

* feat: Add RoutingFormTraceService domain class for type-safe routing traces

- Create RoutingFormTraceService class with routeMatched, fallbackRouteUsed,
  attributeLogicEvaluated, and attributeFallbackUsed methods
- Add ROUTING_FORM_STEPS constants
- Add unit tests

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

* feat: Integrate RoutingFormTraceService for routing form tracing

- Add routingFormTraceService parameter to findMatchingRoute
- Record route_matched or fallback_route_used step when route is selected
- Use "Default Route" as name for form-level fallback routes
- Refactor findTeamMembersMatchingAttributeLogic to use RoutingFormTraceService
- Update handleResponse to accept and pass routingFormTraceService
- Create RoutingFormTraceService in getRoutedUrl and pass through the flow

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

* test: Add test coverage for RoutingFormTraceService integration

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

* feat: Integrate RoutingFormTraceService for routing form tracing

- Add routingFormTraceService parameter to findMatchingRoute
- Record route_matched or fallback_route_used step when route is selected
- Use "Default Route" as name for form-level fallback routes
- Refactor findTeamMembersMatchingAttributeLogic to use RoutingFormTraceService
- Update handleResponse to accept and pass routingFormTraceService
- Create RoutingFormTraceService in getRoutedUrl and pass through the flow

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

* fix: Restore schema and migration from main branch

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

* fix: Use correct parameter name routingFormTraceService in tests

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

* fix: Use 'default_route' instead of 'Default Route' for fallback route name

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.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>
2026-01-28 17:15:22 -03:00

256 lines
8.9 KiB
TypeScript

import routerGetCrmContactOwnerEmail from "@calcom/app-store/routing-forms/lib/crmRouting/routerGetCrmContactOwnerEmail";
import {
onSubmissionOfFormResponse,
type TargetRoutingFormForResponse,
} from "@calcom/app-store/routing-forms/lib/formSubmissionUtils";
import isRouter from "@calcom/app-store/routing-forms/lib/isRouter";
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";
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/lib/server/repository/formResponse";
import { prisma } from "@calcom/prisma";
import { Prisma } from "@calcom/prisma/client";
import { z } from "zod";
import { findTeamMembersMatchingAttributeLogic } from "./findTeamMembersMatchingAttributeLogic";
const moduleLogger = logger.getSubLogger({ prefix: ["routing-forms/lib/handleResponse"] });
const _handleResponse = async ({
response,
identifierKeyedResponse,
form,
// Unused but probably should be used
// formFillerId,
chosenRouteId,
isPreview,
queueFormResponse,
fetchCrm,
traceService,
routingFormTraceService,
}: {
response: Record<
string,
{
value: string | number | string[];
label: string;
identifier?: string;
}
>;
identifierKeyedResponse: Record<string, string | string[]> | null;
form: TargetRoutingFormForResponse;
formFillerId: string;
chosenRouteId: string | null;
isPreview: boolean;
queueFormResponse?: boolean;
fetchCrm?: boolean;
traceService?: RoutingTraceService;
routingFormTraceService?: RoutingFormTraceService;
}) => {
try {
if (!form.fields) {
// There is no point in submitting a form that doesn't have fields defined
throw new HttpError({ statusCode: 400 });
}
const formTeamId = form.teamId;
const formOrgId = form.team?.parentId ?? null;
const serializableFormWithFields = {
...form,
fields: form.fields,
};
const missingFields = serializableFormWithFields.fields
.filter((field) => !(field.required ? response[field.id]?.value : true))
.map((f) => f.label);
if (missingFields.length) {
throw new HttpError({
statusCode: 400,
message: `Missing required fields ${missingFields.join(", ")}`,
});
}
const invalidFields = serializableFormWithFields.fields
.filter((field) => {
const fieldValue = response[field.id]?.value;
// The field isn't required at this point. Validate only if it's set
if (!fieldValue) {
return false;
}
let schema;
if (field.type === "email") {
schema = emailSchema;
} else if (field.type === "phone") {
schema = z.any();
} else {
schema = z.any();
}
return !schema.safeParse(fieldValue).success;
})
.map((f) => ({ label: f.label, type: f.type, value: response[f.id]?.value }));
if (invalidFields.length) {
throw new HttpError({
statusCode: 400,
message: `Invalid value for fields ${invalidFields
.map((f) => `'${f.label}' with value '${f.value}' should be valid ${f.type}`)
.join(", ")}`,
});
}
const chosenRoute = serializableFormWithFields.routes?.find((route) => route.id === chosenRouteId);
let teamMemberIdsMatchingAttributeLogic: number[] | null = null;
let crmContactOwnerEmail: string | null = null;
let crmContactOwnerRecordType: string | null = null;
let crmAppSlug: string | null = null;
let crmRecordId: string | null = null;
let timeTaken: Record<string, number | null> = {};
let checkedFallback = false;
if (chosenRoute) {
if (isRouter(chosenRoute)) {
throw new HttpError({
statusCode: 400,
message: "Chosen route is a router",
});
}
const getRoutedMembers = async () =>
await Promise.all([
(async () => {
const contactOwnerQuery =
identifierKeyedResponse && fetchCrm
? await routerGetCrmContactOwnerEmail({
attributeRoutingConfig: chosenRoute.attributeRoutingConfig,
identifierKeyedResponse,
action: chosenRoute.action,
routingTraceService: traceService,
})
: null;
crmContactOwnerEmail = contactOwnerQuery?.email ?? null;
crmContactOwnerRecordType = contactOwnerQuery?.recordType ?? null;
crmAppSlug = contactOwnerQuery?.crmAppSlug ?? null;
crmRecordId = contactOwnerQuery?.recordId ?? null;
})(),
(async () => {
const teamMembersMatchingAttributeLogicWithResult =
formTeamId && formOrgId
? await findTeamMembersMatchingAttributeLogic(
{
dynamicFieldValueOperands: {
response,
fields: form.fields || [],
},
attributesQueryValue: chosenRoute.attributesQueryValue ?? null,
fallbackAttributesQueryValue: chosenRoute.fallbackAttributesQueryValue,
teamId: formTeamId,
orgId: formOrgId,
routeName: chosenRoute.name,
routeIsFallback: chosenRoute.isFallback,
},
{
enablePerf: true,
routingFormTraceService,
}
)
: null;
moduleLogger.debug(
"teamMembersMatchingAttributeLogic",
safeStringify({ teamMembersMatchingAttributeLogicWithResult })
);
teamMemberIdsMatchingAttributeLogic =
teamMembersMatchingAttributeLogicWithResult?.teamMembersMatchingAttributeLogic
? teamMembersMatchingAttributeLogicWithResult.teamMembersMatchingAttributeLogic.map(
(member) => member.userId
)
: null;
timeTaken = teamMembersMatchingAttributeLogicWithResult?.timeTaken ?? {};
checkedFallback = teamMembersMatchingAttributeLogicWithResult?.checkedFallback ?? false;
})(),
]);
await withReporting(getRoutedMembers, "getRoutedMembers")();
} else {
// It currently happens for a Router route. Such a route id isn't present in the form.routes
}
let dbFormResponse, queuedFormResponse;
if (!isPreview) {
const formResponseRepo = new RoutingFormResponseRepository(prisma);
if (queueFormResponse) {
queuedFormResponse = await formResponseRepo.recordQueuedFormResponse({
formId: form.id,
response,
chosenRouteId,
});
dbFormResponse = null;
} else {
dbFormResponse = await formResponseRepo.recordFormResponse({
formId: form.id,
response,
chosenRouteId,
});
queuedFormResponse = null;
await onSubmissionOfFormResponse({
form: serializableFormWithFields,
formResponseInDb: dbFormResponse,
chosenRouteAction: chosenRoute ? ("action" in chosenRoute ? chosenRoute.action : null) : null,
});
}
} else {
moduleLogger.debug("Dry run mode - Form response not stored and also webhooks and emails not sent");
if (queueFormResponse) {
queuedFormResponse = {
id: "00000000-0000-0000-0000-000000000000",
formId: form.id,
response,
};
} else {
// Create a mock response for dry run
dbFormResponse = {
id: 0,
formId: form.id,
response,
chosenRouteId,
createdAt: new Date(),
updatedAt: new Date(),
};
}
}
return {
isPreview: !!isPreview,
formResponse: dbFormResponse,
queuedFormResponse,
teamMembersMatchingAttributeLogic: teamMemberIdsMatchingAttributeLogic,
crmContactOwnerEmail,
crmContactOwnerRecordType,
crmAppSlug,
crmRecordId,
attributeRoutingConfig: chosenRoute
? "attributeRoutingConfig" in chosenRoute
? chosenRoute.attributeRoutingConfig
: null
: null,
checkedFallback,
timeTaken,
};
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === "P2002") {
throw new HttpError({
statusCode: 409,
message: "Form response already exists",
});
}
}
throw e;
}
};
export const handleResponse = withReporting(_handleResponse, "handleResponse");