* 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>
256 lines
8.9 KiB
TypeScript
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");
|