feat: Use RoutingTraceService to write assignment reasons (#27225)
* 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 <> * fix: Use CURRENT_TIMESTAMP for createdAt defaults (standard Cal.com pattern) Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: Use @default(now()) for createdAt in routing trace tables to match migration 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: Pedro Castro <pedro@cal.com>
This commit is contained in:
co-authored by
unknown
joe@cal.com
Devin AI
Pedro Castro
parent
c9bd18e866
commit
620445d85f
@@ -5,6 +5,7 @@ import { z, ZodError } from "zod";
|
||||
import { onSubmissionOfFormResponse } from "@calcom/app-store/routing-forms/lib/formSubmissionUtils";
|
||||
import { getResponseToStore } from "@calcom/app-store/routing-forms/lib/getResponseToStore";
|
||||
import { getSerializableForm } from "@calcom/app-store/routing-forms/lib/getSerializableForm";
|
||||
import { PrismaPendingRoutingTraceRepository } from "@calcom/features/routing-trace/repositories/PrismaPendingRoutingTraceRepository";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { RoutingFormResponseRepository } from "@calcom/lib/server/repository/formResponse";
|
||||
@@ -58,6 +59,18 @@ export const queuedResponseHandler = async ({
|
||||
chosenRouteId: queuedFormResponse.chosenRouteId,
|
||||
});
|
||||
|
||||
// Link the pending routing trace to the new formResponseId so it can be found when booking is created
|
||||
try {
|
||||
const pendingTraceRepo = new PrismaPendingRoutingTraceRepository(prisma);
|
||||
await pendingTraceRepo.linkToFormResponse({
|
||||
queuedFormResponseId: queuedFormResponse.id,
|
||||
formResponseId: formResponse.id,
|
||||
});
|
||||
} catch (error) {
|
||||
// Log but don't fail - trace linking is not critical
|
||||
logger.warn("Failed to link pending routing trace to form response", safeStringify(error));
|
||||
}
|
||||
|
||||
const chosenRoute = serializableForm.routes?.find((r) => r.id === queuedFormResponse.chosenRouteId);
|
||||
await onSubmissionOfFormResponse({
|
||||
form: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getCRMContactOwnerForRRLeadSkip } from "@calcom/app-store/_utils/CRMRoundRobinSkip";
|
||||
import { EventTypeRepository } from "@calcom/features/eventtypes/repositories/eventTypeRepository";
|
||||
import type { RoutingTraceService } from "@calcom/features/routing-trace/services/RoutingTraceService";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
|
||||
@@ -10,10 +11,12 @@ export default async function routerGetCrmContactOwnerEmail({
|
||||
attributeRoutingConfig,
|
||||
identifierKeyedResponse,
|
||||
action,
|
||||
routingTraceService,
|
||||
}: {
|
||||
attributeRoutingConfig: LocalRoute["attributeRoutingConfig"];
|
||||
identifierKeyedResponse: Record<string, string | string[]> | null;
|
||||
action: LocalRoute["action"];
|
||||
routingTraceService?: RoutingTraceService;
|
||||
}) {
|
||||
// Check if route is skipping CRM contact check
|
||||
if (attributeRoutingConfig?.skipContactOwner) return null;
|
||||
@@ -23,7 +26,8 @@ export default async function routerGetCrmContactOwnerEmail({
|
||||
for (const identifier of Object.keys(identifierKeyedResponse)) {
|
||||
const fieldResponse = identifierKeyedResponse[identifier];
|
||||
if (identifier === "email") {
|
||||
prospectEmail = fieldResponse instanceof Array ? fieldResponse[0] : fieldResponse;
|
||||
prospectEmail =
|
||||
fieldResponse instanceof Array ? fieldResponse[0] : fieldResponse;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -31,11 +35,15 @@ export default async function routerGetCrmContactOwnerEmail({
|
||||
if (!prospectEmail) return null;
|
||||
|
||||
// Determine if the action is an event type redirect
|
||||
if (action.type !== "eventTypeRedirectUrl" || !action.eventTypeId) return null;
|
||||
if (action.type !== "eventTypeRedirectUrl" || !action.eventTypeId)
|
||||
return null;
|
||||
|
||||
const eventTypeRepo = new EventTypeRepository(prisma);
|
||||
const eventType = await eventTypeRepo.findByIdIncludeHostsAndTeam({ id: action.eventTypeId });
|
||||
if (!eventType || eventType.schedulingType !== SchedulingType.ROUND_ROBIN) return null;
|
||||
const eventType = await eventTypeRepo.findByIdIncludeHostsAndTeam({
|
||||
id: action.eventTypeId,
|
||||
});
|
||||
if (!eventType || eventType.schedulingType !== SchedulingType.ROUND_ROBIN)
|
||||
return null;
|
||||
|
||||
const eventTypeMetadata = eventType.metadata;
|
||||
if (!eventTypeMetadata) return null;
|
||||
@@ -53,16 +61,23 @@ export default async function routerGetCrmContactOwnerEmail({
|
||||
};
|
||||
// Determine if there is a CRM option enabled in the chosen route
|
||||
for (const appSlug of enabledAppSlugs) {
|
||||
const routingOptions = attributeRoutingConfig?.[appSlug as keyof typeof attributeRoutingConfig];
|
||||
const routingOptions =
|
||||
attributeRoutingConfig?.[appSlug as keyof typeof attributeRoutingConfig];
|
||||
|
||||
if (!routingOptions) continue;
|
||||
// See if any options are true
|
||||
if (Object.values(routingOptions).some((option) => option === true)) {
|
||||
const appBookingFormHandler = (await import("@calcom/app-store/routing-forms/appBookingFormHandler"))
|
||||
.default;
|
||||
const appHandler = appBookingFormHandler[appSlug as keyof typeof appBookingFormHandler];
|
||||
const appBookingFormHandler = (
|
||||
await import("@calcom/app-store/routing-forms/appBookingFormHandler")
|
||||
).default;
|
||||
const appHandler =
|
||||
appBookingFormHandler[appSlug as keyof typeof appBookingFormHandler];
|
||||
|
||||
const ownerQuery = await appHandler(prospectEmail, attributeRoutingConfig, action.eventTypeId);
|
||||
const ownerQuery = await appHandler(
|
||||
prospectEmail,
|
||||
attributeRoutingConfig,
|
||||
action.eventTypeId
|
||||
);
|
||||
|
||||
if (ownerQuery?.email) {
|
||||
contactOwner = { ...ownerQuery, crmAppSlug: appSlug };
|
||||
@@ -72,13 +87,33 @@ export default async function routerGetCrmContactOwnerEmail({
|
||||
}
|
||||
|
||||
if (!contactOwner || (!contactOwner.email && !contactOwner.recordType)) {
|
||||
const ownerQuery = await getCRMContactOwnerForRRLeadSkip(prospectEmail, eventTypeMetadata);
|
||||
const ownerQuery = await getCRMContactOwnerForRRLeadSkip(
|
||||
prospectEmail,
|
||||
eventTypeMetadata
|
||||
);
|
||||
if (ownerQuery?.email) contactOwner = ownerQuery;
|
||||
}
|
||||
if (!contactOwner) return null;
|
||||
|
||||
// Check that the contact owner is a part of the event type
|
||||
if (!eventType.hosts.some((host) => host.user.email === contactOwner.email)) return null;
|
||||
if (!eventType.hosts.some((host) => host.user.email === contactOwner.email))
|
||||
return null;
|
||||
|
||||
if (routingTraceService && contactOwner.email && contactOwner.crmAppSlug) {
|
||||
const salesforceSettings = attributeRoutingConfig?.salesforce;
|
||||
routingTraceService.addStep({
|
||||
domain: contactOwner.crmAppSlug,
|
||||
step: `${contactOwner.crmAppSlug}_assignment`,
|
||||
data: {
|
||||
email: contactOwner.email,
|
||||
recordType: contactOwner.recordType,
|
||||
recordId: contactOwner.recordId,
|
||||
crmAppSlug: contactOwner.crmAppSlug,
|
||||
rrSkipToAccountLookupField: salesforceSettings?.rrSkipToAccountLookupField,
|
||||
rrSKipToAccountLookupFieldName: salesforceSettings?.rrSKipToAccountLookupFieldName,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return contactOwner;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ import { BookingEmailAndSmsTasker } from "@calcom/features/bookings/lib/tasker/B
|
||||
import { getSpamCheckService } from "@calcom/features/di/watchlist/containers/SpamCheckService.container";
|
||||
import { CreditService } from "@calcom/features/ee/billing/credit-service";
|
||||
import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer";
|
||||
import { getRoutingTraceService } from "@calcom/features/routing-trace/di/RoutingTraceService.container";
|
||||
import AssignmentReasonRecorder from "@calcom/features/ee/round-robin/assignmentReason/AssignmentReasonRecorder";
|
||||
import { BookingLocationService } from "@calcom/features/ee/round-robin/lib/bookingLocationService";
|
||||
import { getAllWorkflowsFromEventType } from "@calcom/features/ee/workflows/lib/getAllWorkflowsFromEventType";
|
||||
@@ -1945,25 +1946,22 @@ async function handler(
|
||||
}
|
||||
|
||||
// If it's a round robin event, record the reason for the host assignment
|
||||
if (eventType.schedulingType === SchedulingType.ROUND_ROBIN) {
|
||||
if (reqBody.crmOwnerRecordType && reqBody.crmAppSlug && contactOwnerEmail && routingFormResponseId) {
|
||||
assignmentReason = await AssignmentReasonRecorder.CRMOwnership({
|
||||
if (eventType.schedulingType === SchedulingType.ROUND_ROBIN && routingFormResponseId) {
|
||||
try {
|
||||
const routingTraceService = getRoutingTraceService();
|
||||
const result = await routingTraceService.processForBooking({
|
||||
formResponseId: routingFormResponseId,
|
||||
bookingId: booking.id,
|
||||
crmAppSlug: reqBody.crmAppSlug,
|
||||
teamMemberEmail: contactOwnerEmail,
|
||||
recordType: reqBody.crmOwnerRecordType,
|
||||
routingFormResponseId,
|
||||
recordId: crmRecordId,
|
||||
});
|
||||
} else if (routingFormResponseId && teamId) {
|
||||
assignmentReason = await AssignmentReasonRecorder.routingFormRoute({
|
||||
bookingId: booking.id,
|
||||
routingFormResponseId,
|
||||
organizerId: organizerUser.id,
|
||||
teamId,
|
||||
bookingUid: booking.uid,
|
||||
organizerEmail: organizerUser.email,
|
||||
isRerouting: !!reroutingFormResponses,
|
||||
reroutedByEmail: reqBody.rescheduledBy,
|
||||
});
|
||||
if (result?.assignmentReason) {
|
||||
assignmentReason = result.assignmentReason;
|
||||
}
|
||||
} catch (error) {
|
||||
criticalLogger.warn("Failed to process routing trace", { error });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,11 @@ import { Utils as QbUtils } from "react-awesome-query-builder/lib";
|
||||
import { acrossQueryValueCompatiblity, raqbQueryValueUtils } from "@calcom/app-store/_utils/raqb/raqbUtils";
|
||||
import type { Attribute } from "@calcom/app-store/routing-forms/types/types";
|
||||
import { getAttributesAssignmentData } from "@calcom/features/attributes/lib/getAttributes";
|
||||
import type { RoutingTraceService } from "@calcom/features/routing-trace/services/RoutingTraceService";
|
||||
import {
|
||||
ROUTING_TRACE_DOMAINS,
|
||||
ROUTING_TRACE_STEPS,
|
||||
} from "@calcom/features/routing-trace/services/RoutingTraceService";
|
||||
import { RaqbLogicResult } from "@calcom/lib/raqb/evaluateRaqbLogic";
|
||||
import jsonLogic from "@calcom/lib/raqb/jsonLogic";
|
||||
import type { dynamicFieldValueOperands, AttributesQueryValue } from "@calcom/lib/raqb/types";
|
||||
@@ -130,6 +135,136 @@ function buildTroubleshooterData({ type, data }: { type: TroubleshooterCase; dat
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a string is a field template placeholder (e.g., "{field:uuid}")
|
||||
*/
|
||||
function isFieldTemplate(str: string): boolean {
|
||||
const trimmedStr = str.trim();
|
||||
return trimmedStr.startsWith("{field:") && trimmedStr.endsWith("}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts field ID from a field template string
|
||||
*/
|
||||
function extractFieldIdFromTemplate(template: string): string | null {
|
||||
const trimmedTemplate = template.trim();
|
||||
if (!isFieldTemplate(trimmedTemplate)) return null;
|
||||
return trimmedTemplate.slice(7, -1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a field template to its actual value from the form response
|
||||
*/
|
||||
function resolveFieldTemplateValue(
|
||||
value: string,
|
||||
dynamicFieldValueOperands?: dynamicFieldValueOperands
|
||||
): string {
|
||||
if (!isFieldTemplate(value) || !dynamicFieldValueOperands) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const fieldId = extractFieldIdFromTemplate(value);
|
||||
if (!fieldId) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const { fields, response } = dynamicFieldValueOperands;
|
||||
const field = fields.find((f) => f.id === fieldId);
|
||||
|
||||
if (!field) {
|
||||
// Field definition not found - return original template
|
||||
return value;
|
||||
}
|
||||
|
||||
const fieldResponse = response[field.id];
|
||||
if (!fieldResponse) {
|
||||
// Field not in response object - return original template
|
||||
return value;
|
||||
}
|
||||
|
||||
const fieldValue = fieldResponse.value;
|
||||
if (fieldValue === null || fieldValue === undefined || fieldValue === "") {
|
||||
return "(empty)";
|
||||
}
|
||||
|
||||
if (Array.isArray(fieldValue)) {
|
||||
return fieldValue.join(", ");
|
||||
}
|
||||
|
||||
return String(fieldValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts attribute name/value pairs from a resolved attributesQueryValue.
|
||||
* This is used to build the assignment reason string.
|
||||
*/
|
||||
function extractAttributeRoutingDetails({
|
||||
resolvedAttributesQueryValue,
|
||||
attributesOfTheOrg,
|
||||
dynamicFieldValueOperands,
|
||||
}: {
|
||||
resolvedAttributesQueryValue: AttributesQueryValue | null;
|
||||
attributesOfTheOrg: Attribute[];
|
||||
dynamicFieldValueOperands?: dynamicFieldValueOperands;
|
||||
}): Array<{ attributeName: string; attributeValue: string }> {
|
||||
if (!resolvedAttributesQueryValue) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!raqbQueryValueUtils.isQueryValueARuleGroup(resolvedAttributesQueryValue as JsonTree)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const children1 = resolvedAttributesQueryValue.children1;
|
||||
if (!children1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const result: Array<{ attributeName: string; attributeValue: string }> = [];
|
||||
|
||||
for (const ruleId of Object.keys(children1)) {
|
||||
const rule = children1[ruleId];
|
||||
// Check if it's a rule (has properties with field)
|
||||
if (rule.type !== "rule" || !rule.properties) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const properties = rule.properties;
|
||||
const fieldId = properties.field;
|
||||
const value = properties.value;
|
||||
|
||||
if (!fieldId || !value || value[0] === null || value[0] === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const attribute = attributesOfTheOrg.find((attr) => attr.id === fieldId);
|
||||
if (!attribute) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const attributeValueString = (() => {
|
||||
const firstValue = value[0];
|
||||
if (Array.isArray(firstValue)) {
|
||||
// Handle array values - resolve any field templates within
|
||||
const resolvedValues = firstValue.map((v) =>
|
||||
typeof v === "string" ? resolveFieldTemplateValue(v, dynamicFieldValueOperands) : String(v)
|
||||
);
|
||||
return resolvedValues.join(", ");
|
||||
}
|
||||
const stringValue = String(firstValue);
|
||||
// Resolve field template if present
|
||||
return resolveFieldTemplateValue(stringValue, dynamicFieldValueOperands);
|
||||
})();
|
||||
|
||||
result.push({
|
||||
attributeName: attribute.name,
|
||||
attributeValue: attributeValueString,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function getLogicResultForAllMembers(
|
||||
{
|
||||
teamMembersWithAttributeOptionValuePerAttribute,
|
||||
@@ -193,6 +328,7 @@ async function runAttributeLogic(data: RunAttributeLogicData, options: RunAttrib
|
||||
return {
|
||||
logicBuildingWarnings: null,
|
||||
teamMembersMatchingAttributeLogic: null,
|
||||
resolvedAttributesQueryValue: null,
|
||||
timeTaken: null,
|
||||
...buildTroubleshooterData({
|
||||
type: TroubleshooterCase.MATCHES_ALL_MEMBERS_BECAUSE_OF_EMPTY_QUERY_VALUE,
|
||||
@@ -217,6 +353,7 @@ async function runAttributeLogic(data: RunAttributeLogicData, options: RunAttrib
|
||||
return {
|
||||
teamMembersMatchingAttributeLogic: null,
|
||||
logicBuildingWarnings: null,
|
||||
resolvedAttributesQueryValue: attributesQueryValue,
|
||||
timeTaken: {
|
||||
ttgetAttributesQueryBuilderConfigHavingListofLabels,
|
||||
},
|
||||
@@ -256,6 +393,7 @@ async function runAttributeLogic(data: RunAttributeLogicData, options: RunAttrib
|
||||
return {
|
||||
teamMembersMatchingAttributeLogic,
|
||||
logicBuildingWarnings,
|
||||
resolvedAttributesQueryValue: attributesQueryValue,
|
||||
timeTaken: {
|
||||
ttgetAttributesQueryBuilderConfigHavingListofLabels,
|
||||
ttTeamMembersMatchingAttributeLogic,
|
||||
@@ -322,15 +460,18 @@ export async function findTeamMembersMatchingAttributeLogic(
|
||||
fallbackAttributesQueryValue?: AttributesQueryValue | null;
|
||||
dynamicFieldValueOperands?: dynamicFieldValueOperands;
|
||||
isPreview?: boolean;
|
||||
routeName?: string;
|
||||
routeIsFallback?: boolean;
|
||||
},
|
||||
options: {
|
||||
enablePerf?: boolean;
|
||||
concurrency?: number;
|
||||
enableTroubleshooter?: boolean;
|
||||
routingTraceService?: RoutingTraceService;
|
||||
} = {}
|
||||
) {
|
||||
// Higher value of concurrency might not be performant as it might overwhelm the system. So, use a lower value as default.
|
||||
const { enablePerf = false, concurrency = 2, enableTroubleshooter = false } = options;
|
||||
const { enablePerf = false, concurrency = 2, enableTroubleshooter = false, routingTraceService } = options;
|
||||
|
||||
// Any explicit value being passed should cause fallback to be considered. Even undefined
|
||||
const considerFallback = "fallbackAttributesQueryValue" in data;
|
||||
@@ -342,6 +483,8 @@ export async function findTeamMembersMatchingAttributeLogic(
|
||||
fallbackAttributesQueryValue,
|
||||
dynamicFieldValueOperands,
|
||||
isPreview,
|
||||
routeName,
|
||||
routeIsFallback,
|
||||
} = data;
|
||||
|
||||
const {
|
||||
@@ -374,6 +517,7 @@ export async function findTeamMembersMatchingAttributeLogic(
|
||||
timeTaken: teamMembersMatchingMainAttributeLogicTimeTaken,
|
||||
troubleshooter,
|
||||
logicBuildingWarnings: mainAttributeLogicBuildingWarnings,
|
||||
resolvedAttributesQueryValue,
|
||||
} = await runMainAttributeLogic(
|
||||
{
|
||||
...runAttributeLogicData,
|
||||
@@ -382,14 +526,39 @@ export async function findTeamMembersMatchingAttributeLogic(
|
||||
runAttributeLogicOptions
|
||||
);
|
||||
|
||||
// Helper to add trace step for attribute logic evaluation
|
||||
const addTraceStep = (checkedFallback: boolean) => {
|
||||
if (routingTraceService) {
|
||||
const attributeRoutingDetails = extractAttributeRoutingDetails({
|
||||
resolvedAttributesQueryValue,
|
||||
attributesOfTheOrg,
|
||||
dynamicFieldValueOperands,
|
||||
});
|
||||
|
||||
routingTraceService.addStep({
|
||||
domain: ROUTING_TRACE_DOMAINS.ROUTING_FORM,
|
||||
step: ROUTING_TRACE_STEPS.ATTRIBUTE_LOGIC_EVALUATED,
|
||||
data: {
|
||||
routeName,
|
||||
routeIsFallback,
|
||||
checkedFallback,
|
||||
attributeRoutingDetails,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// It being null means that no logic was found and thus all members match. In such case, we don't fallback intentionally.
|
||||
// This is the case when user added no rules so, he expects to match all members
|
||||
if (!teamMembersMatchingMainAttributeLogic) {
|
||||
addTraceStep(false);
|
||||
return {
|
||||
teamMembersMatchingAttributeLogic: null,
|
||||
checkedFallback: false,
|
||||
mainAttributeLogicBuildingWarnings,
|
||||
fallbackAttributeLogicBuildingWarnings: [],
|
||||
routeName,
|
||||
routeIsFallback,
|
||||
timeTaken: {
|
||||
...teamMembersMatchingMainAttributeLogicTimeTaken,
|
||||
ttGetAttributesForLogic,
|
||||
@@ -418,11 +587,14 @@ export async function findTeamMembersMatchingAttributeLogic(
|
||||
runAttributeLogicOptions
|
||||
);
|
||||
|
||||
addTraceStep(true);
|
||||
return {
|
||||
teamMembersMatchingAttributeLogic: teamMembersMatchingFallbackLogic,
|
||||
checkedFallback: true,
|
||||
fallbackAttributeLogicBuildingWarnings,
|
||||
mainAttributeLogicBuildingWarnings,
|
||||
routeName,
|
||||
routeIsFallback,
|
||||
timeTaken: {
|
||||
...teamMembersMatchingFallbackLogicTimeTaken,
|
||||
ttGetAttributesForLogic,
|
||||
@@ -436,11 +608,14 @@ export async function findTeamMembersMatchingAttributeLogic(
|
||||
};
|
||||
}
|
||||
|
||||
addTraceStep(false);
|
||||
return {
|
||||
teamMembersMatchingAttributeLogic: teamMembersMatchingMainAttributeLogic,
|
||||
checkedFallback: false,
|
||||
mainAttributeLogicBuildingWarnings,
|
||||
fallbackAttributeLogicBuildingWarnings: [],
|
||||
routeName,
|
||||
routeIsFallback,
|
||||
timeTaken: {
|
||||
...teamMembersMatchingMainAttributeLogicTimeTaken,
|
||||
ttGetAttributesForLogic,
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import "@calcom/lib/__mocks__/logger";
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getAbsoluteEventTypeRedirectUrlWithEmbedSupport } from "@calcom/app-store/routing-forms/getEventTypeRedirectUrl";
|
||||
import { getResponseToStore } from "@calcom/app-store/routing-forms/lib/getResponseToStore";
|
||||
import { getSerializableForm } from "@calcom/app-store/routing-forms/lib/getSerializableForm";
|
||||
@@ -14,6 +11,8 @@ import { isAuthorizedToViewFormOnOrgDomain } from "@calcom/features/routing-form
|
||||
import { PrismaRoutingFormRepository } from "@calcom/features/routing-forms/repositories/PrismaRoutingFormRepository";
|
||||
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getRoutedUrl } from "./getRoutedUrl";
|
||||
import { getUrlSearchParamsToForward } from "./getUrlSearchParamsToForward";
|
||||
@@ -26,9 +25,11 @@ vi.mock("@calcom/lib/checkRateLimitAndThrowError");
|
||||
vi.mock("@calcom/features/routing-forms/repositories/PrismaRoutingFormRepository");
|
||||
vi.mock("@calcom/features/users/repositories/UserRepository", () => {
|
||||
return {
|
||||
UserRepository: vi.fn().mockImplementation(function() { return {
|
||||
enrichUserWithItsProfile: vi.fn(),
|
||||
}; }),
|
||||
UserRepository: vi.fn().mockImplementation(function () {
|
||||
return {
|
||||
enrichUserWithItsProfile: vi.fn(),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock("@calcom/features/ee/organizations/lib/orgDomains");
|
||||
@@ -44,8 +45,33 @@ vi.mock("@calcom/app-store/routing-forms/enrichFormWithMigrationData", () => ({
|
||||
vi.mock("@calcom/lib/sentryWrapper", () => ({
|
||||
withReporting: (fn: unknown) => fn,
|
||||
}));
|
||||
vi.mock("@calcom/features/routing-trace/di/RoutingTraceService.container", () => ({
|
||||
getRoutingTraceService: vi.fn(() => ({
|
||||
addStep: vi.fn(),
|
||||
getStepsCount: vi.fn().mockReturnValue(0),
|
||||
savePendingRoutingTrace: vi.fn().mockResolvedValue(undefined),
|
||||
processForBooking: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockForm = {
|
||||
const mockForm: {
|
||||
id: string;
|
||||
user: { id: number; name: string };
|
||||
team: null;
|
||||
name: string;
|
||||
fields: never[];
|
||||
routes: never[];
|
||||
userId: number;
|
||||
teamId: null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
description: null;
|
||||
enabled: boolean;
|
||||
isDefault: boolean;
|
||||
fieldsOrder: never[];
|
||||
position: number;
|
||||
slug: string;
|
||||
} = {
|
||||
id: "form-id",
|
||||
user: { id: 1, name: "Test User" },
|
||||
team: null,
|
||||
@@ -64,7 +90,12 @@ const mockForm = {
|
||||
slug: "test-form",
|
||||
};
|
||||
|
||||
const mockSerializableForm = {
|
||||
const mockSerializableForm: {
|
||||
id: string;
|
||||
fields: { id: string; type: string; label: string; identifier: string }[];
|
||||
routes: never[];
|
||||
user: { id: number };
|
||||
} = {
|
||||
id: "form-id",
|
||||
fields: [{ id: "email", type: "email", label: "Email", identifier: "email" }],
|
||||
routes: [],
|
||||
@@ -73,7 +104,7 @@ const mockSerializableForm = {
|
||||
|
||||
const mockContext = (
|
||||
query: Record<string, unknown>,
|
||||
url = "/link/form-id"
|
||||
url: string = "/link/form-id"
|
||||
): Pick<GetServerSidePropsContext, "query" | "req"> => ({
|
||||
query: { form: "form-id", ...query },
|
||||
req: { url },
|
||||
@@ -92,8 +123,7 @@ describe("getRoutedUrl", () => {
|
||||
mockUserRepository.mockImplementation(function () {
|
||||
return {
|
||||
enrichUserWithItsProfile: mockEnrichUserWithItsProfile,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any;
|
||||
} as unknown as InstanceType<typeof UserRepository>;
|
||||
});
|
||||
}
|
||||
vi.mocked(isAuthorizedToViewFormOnOrgDomain).mockReturnValue(true);
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { FormResponse } from "@calcom/app-store/routing-forms/types/types";
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { isAuthorizedToViewFormOnOrgDomain } from "@calcom/features/routing-forms/lib/isAuthorizedToViewForm";
|
||||
import { PrismaRoutingFormRepository } from "@calcom/features/routing-forms/repositories/PrismaRoutingFormRepository";
|
||||
import { getRoutingTraceService } from "@calcom/features/routing-trace/di/RoutingTraceService.container";
|
||||
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
@@ -36,7 +37,9 @@ const querySchema = z
|
||||
})
|
||||
.catchall(z.string().or(z.array(z.string())));
|
||||
|
||||
const getDeterministicHashForResponse = (fieldsResponses: Record<string, unknown>) => {
|
||||
const getDeterministicHashForResponse = (
|
||||
fieldsResponses: Record<string, unknown>
|
||||
) => {
|
||||
const sortedFields = Object.keys(fieldsResponses)
|
||||
.sort()
|
||||
.reduce((obj: Record<string, unknown>, key) => {
|
||||
@@ -53,7 +56,13 @@ export function hasEmbedPath(pathWithQuery: string) {
|
||||
return onlyPath.endsWith("/embed") || onlyPath.endsWith("/embed/");
|
||||
}
|
||||
|
||||
const _getRoutedUrl = async (context: Pick<GetServerSidePropsContext, "query" | "req">, fetchCrm = true) => {
|
||||
const _getRoutedUrl = async (
|
||||
context: Pick<GetServerSidePropsContext, "query" | "req">,
|
||||
fetchCrm = true
|
||||
) => {
|
||||
// Initialize trace service for tracking routing decisions
|
||||
const routingTraceService = getRoutingTraceService();
|
||||
|
||||
const queryParsed = querySchema.safeParse(context.query);
|
||||
const isEmbed = hasEmbedPath(context.req.url || "");
|
||||
const pageProps = {
|
||||
@@ -87,7 +96,9 @@ const _getRoutedUrl = async (context: Pick<GetServerSidePropsContext, "query" |
|
||||
const paramsToBeForwardedAsIs = {
|
||||
...fieldsResponses,
|
||||
// Must be forwarded if present to Booking Page. Setting it explicitly here as it is critical to be present in the URL.
|
||||
...(isBookingDryRunParam ? { "cal.isBookingDryRun": isBookingDryRunParam } : null),
|
||||
...(isBookingDryRunParam
|
||||
? { "cal.isBookingDryRun": isBookingDryRunParam }
|
||||
: null),
|
||||
};
|
||||
|
||||
const { currentOrgDomain } = orgDomainConfig(context.req);
|
||||
@@ -95,7 +106,8 @@ const _getRoutedUrl = async (context: Pick<GetServerSidePropsContext, "query" |
|
||||
let timeTaken: Record<string, number | null> = {};
|
||||
|
||||
const formQueryStart = performance.now();
|
||||
const form = await PrismaRoutingFormRepository.findFormByIdIncludeUserTeamAndOrg(formId);
|
||||
const form =
|
||||
await PrismaRoutingFormRepository.findFormByIdIncludeUserTeamAndOrg(formId);
|
||||
timeTaken.formQuery = performance.now() - formQueryStart;
|
||||
|
||||
if (!form) {
|
||||
@@ -113,7 +125,11 @@ const _getRoutedUrl = async (context: Pick<GetServerSidePropsContext, "query" |
|
||||
timeTaken.profileEnrichment = performance.now() - profileEnrichmentStart;
|
||||
|
||||
if (
|
||||
!isAuthorizedToViewFormOnOrgDomain({ user: formWithUserProfile.user, currentOrgDomain, team: form.team })
|
||||
!isAuthorizedToViewFormOnOrgDomain({
|
||||
user: formWithUserProfile.user,
|
||||
currentOrgDomain,
|
||||
team: form.team,
|
||||
})
|
||||
) {
|
||||
return {
|
||||
notFound: true,
|
||||
@@ -144,10 +160,10 @@ const _getRoutedUrl = async (context: Pick<GetServerSidePropsContext, "query" |
|
||||
let teamMembersMatchingAttributeLogic = null;
|
||||
let formResponseId = null;
|
||||
let attributeRoutingConfig = null;
|
||||
let queuedFormResponseId;
|
||||
let crmContactOwnerEmail: string | null = null;
|
||||
let crmContactOwnerRecordType: string | null = null;
|
||||
let crmAppSlug: string | null = null;
|
||||
let queuedFormResponseId;
|
||||
try {
|
||||
const result = await handleResponse({
|
||||
form: serializableForm,
|
||||
@@ -158,18 +174,31 @@ const _getRoutedUrl = async (context: Pick<GetServerSidePropsContext, "query" |
|
||||
isPreview: isBookingDryRun,
|
||||
queueFormResponse: shouldQueueFormResponse,
|
||||
fetchCrm,
|
||||
traceService: isBookingDryRun ? undefined : routingTraceService,
|
||||
});
|
||||
teamMembersMatchingAttributeLogic = result.teamMembersMatchingAttributeLogic;
|
||||
teamMembersMatchingAttributeLogic =
|
||||
result.teamMembersMatchingAttributeLogic;
|
||||
formResponseId = result.formResponse?.id;
|
||||
queuedFormResponseId = result.queuedFormResponse?.id;
|
||||
attributeRoutingConfig = result.attributeRoutingConfig;
|
||||
crmContactOwnerEmail = result.crmContactOwnerEmail;
|
||||
crmContactOwnerRecordType = result.crmContactOwnerRecordType;
|
||||
crmAppSlug = result.crmAppSlug;
|
||||
timeTaken = {
|
||||
...timeTaken,
|
||||
...result.timeTaken,
|
||||
};
|
||||
crmContactOwnerEmail = result.crmContactOwnerEmail;
|
||||
crmContactOwnerRecordType = result.crmContactOwnerRecordType;
|
||||
crmAppSlug = result.crmAppSlug;
|
||||
|
||||
// Save the pending trace (trace steps are added inside handleResponse)
|
||||
if (!isBookingDryRun) {
|
||||
if (formResponseId) {
|
||||
await routingTraceService.savePendingRoutingTrace({ formResponseId });
|
||||
} else if (queuedFormResponseId) {
|
||||
await routingTraceService.savePendingRoutingTrace({
|
||||
queuedFormResponseId,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof HttpError || e instanceof TRPCError) {
|
||||
return {
|
||||
@@ -214,17 +243,20 @@ const _getRoutedUrl = async (context: Pick<GetServerSidePropsContext, "query" |
|
||||
formResponse: response,
|
||||
fields: serializableForm.fields,
|
||||
searchParams: new URLSearchParams(
|
||||
stringify({ ...paramsToBeForwardedAsIs, "cal.action": "eventTypeRedirectUrl" })
|
||||
stringify({
|
||||
...paramsToBeForwardedAsIs,
|
||||
"cal.action": "eventTypeRedirectUrl",
|
||||
})
|
||||
),
|
||||
teamMembersMatchingAttributeLogic,
|
||||
formResponseId: formResponseId ?? null,
|
||||
queuedFormResponseId: queuedFormResponseId ?? null,
|
||||
attributeRoutingConfig: attributeRoutingConfig ?? null,
|
||||
teamId: form?.teamId,
|
||||
orgId: form.team?.parentId,
|
||||
crmContactOwnerEmail,
|
||||
crmContactOwnerRecordType,
|
||||
crmAppSlug,
|
||||
teamId: form?.teamId,
|
||||
orgId: form.team?.parentId,
|
||||
}),
|
||||
isEmbed: pageProps.isEmbed,
|
||||
}),
|
||||
@@ -234,7 +266,9 @@ const _getRoutedUrl = async (context: Pick<GetServerSidePropsContext, "query" |
|
||||
} else if (decidedAction.type === "externalRedirectUrl") {
|
||||
return {
|
||||
redirect: {
|
||||
destination: `${decidedAction.value}?${stringify(context.query)}&cal.action=externalRedirectUrl`,
|
||||
destination: `${decidedAction.value}?${stringify(
|
||||
context.query
|
||||
)}&cal.action=externalRedirectUrl`,
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -27,12 +27,12 @@ type GetUrlSearchParamsToForwardOptions = {
|
||||
queuedFormResponseId: string | null;
|
||||
teamMembersMatchingAttributeLogic: number[] | null;
|
||||
attributeRoutingConfig: AttributeRoutingConfig | null;
|
||||
reroutingFormResponses?: FormResponseValueOnly;
|
||||
teamId?: number | null;
|
||||
orgId?: number | null;
|
||||
crmContactOwnerEmail?: string | null;
|
||||
crmContactOwnerRecordType?: string | null;
|
||||
crmAppSlug?: string | null;
|
||||
reroutingFormResponses?: FormResponseValueOnly;
|
||||
teamId?: number | null;
|
||||
orgId?: number | null;
|
||||
};
|
||||
|
||||
export function getUrlSearchParamsToForward({
|
||||
@@ -43,12 +43,12 @@ export function getUrlSearchParamsToForward({
|
||||
formResponseId,
|
||||
queuedFormResponseId,
|
||||
attributeRoutingConfig,
|
||||
reroutingFormResponses,
|
||||
teamId,
|
||||
orgId,
|
||||
crmContactOwnerEmail,
|
||||
crmContactOwnerRecordType,
|
||||
crmAppSlug,
|
||||
reroutingFormResponses,
|
||||
teamId,
|
||||
orgId,
|
||||
}: GetUrlSearchParamsToForwardOptions) {
|
||||
type Params = Record<string, string | string[]>;
|
||||
const paramsFromResponse: Params = {};
|
||||
@@ -118,9 +118,6 @@ export function getUrlSearchParamsToForward({
|
||||
const allQueryParams: Params = {
|
||||
...(teamId && { ["cal.teamId"]: `${teamId}` }),
|
||||
...(orgId && { ["cal.orgId"]: `${orgId}` }),
|
||||
...(crmContactOwnerEmail && { ["cal.crmContactOwnerEmail"]: `${crmContactOwnerEmail}` }),
|
||||
...(crmContactOwnerRecordType && { ["cal.crmContactOwnerRecordType"]: `${crmContactOwnerRecordType}` }),
|
||||
...(crmAppSlug && { ["cal.crmAppSlug"]: `${crmAppSlug}` }),
|
||||
...paramsFromCurrentUrl,
|
||||
// In case of conflict b/w paramsFromResponse and paramsFromCurrentUrl, paramsFromResponse should win as the booker probably improved upon the prefilled value.
|
||||
...paramsFromResponse,
|
||||
@@ -132,6 +129,9 @@ export function getUrlSearchParamsToForward({
|
||||
: null),
|
||||
...(queuedFormResponseId ? { ["cal.queuedFormResponseId"]: queuedFormResponseId } : null),
|
||||
...attributeRoutingConfigParams,
|
||||
...(crmContactOwnerEmail ? { ["cal.crmContactOwnerEmail"]: crmContactOwnerEmail } : null),
|
||||
...(crmContactOwnerRecordType ? { ["cal.crmContactOwnerRecordType"]: crmContactOwnerRecordType } : null),
|
||||
...(crmAppSlug ? { ["cal.crmAppSlug"]: crmAppSlug } : null),
|
||||
...(reroutingFormResponses
|
||||
? { ["cal.reroutingFormResponses"]: JSON.stringify(reroutingFormResponses) }
|
||||
: null),
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type TargetRoutingFormForResponse,
|
||||
} from "@calcom/app-store/routing-forms/lib/formSubmissionUtils";
|
||||
import isRouter from "@calcom/app-store/routing-forms/lib/isRouter";
|
||||
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";
|
||||
@@ -29,6 +30,7 @@ const _handleResponse = async ({
|
||||
isPreview,
|
||||
queueFormResponse,
|
||||
fetchCrm,
|
||||
traceService,
|
||||
}: {
|
||||
response: Record<
|
||||
string,
|
||||
@@ -45,6 +47,7 @@ const _handleResponse = async ({
|
||||
isPreview: boolean;
|
||||
queueFormResponse?: boolean;
|
||||
fetchCrm?: boolean;
|
||||
traceService?: RoutingTraceService;
|
||||
}) => {
|
||||
try {
|
||||
if (!form.fields) {
|
||||
@@ -104,6 +107,7 @@ const _handleResponse = async ({
|
||||
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({
|
||||
@@ -121,6 +125,7 @@ const _handleResponse = async ({
|
||||
attributeRoutingConfig: chosenRoute.attributeRoutingConfig,
|
||||
identifierKeyedResponse,
|
||||
action: chosenRoute.action,
|
||||
routingTraceService: traceService,
|
||||
})
|
||||
: null;
|
||||
crmContactOwnerEmail = contactOwnerQuery?.email ?? null;
|
||||
@@ -141,9 +146,12 @@ const _handleResponse = async ({
|
||||
fallbackAttributesQueryValue: chosenRoute.fallbackAttributesQueryValue,
|
||||
teamId: formTeamId,
|
||||
orgId: formOrgId,
|
||||
routeName: chosenRoute.name,
|
||||
routeIsFallback: chosenRoute.isFallback,
|
||||
},
|
||||
{
|
||||
enablePerf: true,
|
||||
routingTraceService: traceService,
|
||||
}
|
||||
)
|
||||
: null;
|
||||
@@ -161,6 +169,7 @@ const _handleResponse = async ({
|
||||
: null;
|
||||
|
||||
timeTaken = teamMembersMatchingAttributeLogicWithResult?.timeTaken ?? {};
|
||||
checkedFallback = teamMembersMatchingAttributeLogicWithResult?.checkedFallback ?? false;
|
||||
})(),
|
||||
]);
|
||||
|
||||
@@ -226,6 +235,7 @@ const _handleResponse = async ({
|
||||
? chosenRoute.attributeRoutingConfig
|
||||
: null
|
||||
: null,
|
||||
checkedFallback,
|
||||
timeTaken,
|
||||
};
|
||||
} catch (e) {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di";
|
||||
import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma";
|
||||
|
||||
import { PrismaPendingRoutingTraceRepository } from "../repositories/PrismaPendingRoutingTraceRepository";
|
||||
import { ROUTING_TRACE_DI_TOKENS } from "./tokens";
|
||||
|
||||
export const pendingRoutingTraceRepositoryModule = createModule();
|
||||
const token = ROUTING_TRACE_DI_TOKENS.PENDING_ROUTING_TRACE_REPOSITORY;
|
||||
const moduleToken = ROUTING_TRACE_DI_TOKENS.PENDING_ROUTING_TRACE_REPOSITORY_MODULE;
|
||||
const loadModule = bindModuleToClassOnToken({
|
||||
module: pendingRoutingTraceRepositoryModule,
|
||||
moduleToken,
|
||||
token,
|
||||
classs: PrismaPendingRoutingTraceRepository,
|
||||
dep: prismaModuleLoader,
|
||||
});
|
||||
|
||||
export const moduleLoader: ModuleLoader = {
|
||||
token,
|
||||
loadModule,
|
||||
};
|
||||
|
||||
export type { PrismaPendingRoutingTraceRepository };
|
||||
@@ -0,0 +1,23 @@
|
||||
import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di";
|
||||
import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma";
|
||||
|
||||
import { PrismaRoutingTraceRepository } from "../repositories/PrismaRoutingTraceRepository";
|
||||
import { ROUTING_TRACE_DI_TOKENS } from "./tokens";
|
||||
|
||||
export const routingTraceRepositoryModule = createModule();
|
||||
const token = ROUTING_TRACE_DI_TOKENS.ROUTING_TRACE_REPOSITORY;
|
||||
const moduleToken = ROUTING_TRACE_DI_TOKENS.ROUTING_TRACE_REPOSITORY_MODULE;
|
||||
const loadModule = bindModuleToClassOnToken({
|
||||
module: routingTraceRepositoryModule,
|
||||
moduleToken,
|
||||
token,
|
||||
classs: PrismaRoutingTraceRepository,
|
||||
dep: prismaModuleLoader,
|
||||
});
|
||||
|
||||
export const moduleLoader: ModuleLoader = {
|
||||
token,
|
||||
loadModule,
|
||||
};
|
||||
|
||||
export type { PrismaRoutingTraceRepository };
|
||||
@@ -0,0 +1,37 @@
|
||||
import { AssignmentReasonRepository } from "@calcom/features/assignment-reason/repositories/AssignmentReasonRepository";
|
||||
import { createContainer } from "@calcom/features/di/di";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import type { IPendingRoutingTraceRepository } from "../repositories/PendingRoutingTraceRepository.interface";
|
||||
import type { IRoutingTraceRepository } from "../repositories/RoutingTraceRepository.interface";
|
||||
import { RoutingTraceService } from "../services/RoutingTraceService";
|
||||
import { moduleLoader as pendingRoutingTraceRepositoryModuleLoader } from "./PendingRoutingTraceRepository.module";
|
||||
import { moduleLoader as routingTraceRepositoryModuleLoader } from "./RoutingTraceRepository.module";
|
||||
|
||||
const routingTraceContainer = createContainer();
|
||||
|
||||
function getPendingRoutingTraceRepository(): IPendingRoutingTraceRepository {
|
||||
pendingRoutingTraceRepositoryModuleLoader.loadModule(routingTraceContainer);
|
||||
return routingTraceContainer.get<IPendingRoutingTraceRepository>(
|
||||
pendingRoutingTraceRepositoryModuleLoader.token
|
||||
);
|
||||
}
|
||||
|
||||
function getRoutingTraceRepository(): IRoutingTraceRepository {
|
||||
routingTraceRepositoryModuleLoader.loadModule(routingTraceContainer);
|
||||
return routingTraceContainer.get<IRoutingTraceRepository>(routingTraceRepositoryModuleLoader.token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a RoutingTraceService instance with all required repositories.
|
||||
* Use this for processing pending traces during booking creation.
|
||||
*/
|
||||
export function getRoutingTraceService(): RoutingTraceService {
|
||||
return new RoutingTraceService({
|
||||
pendingRoutingTraceRepository: getPendingRoutingTraceRepository(),
|
||||
routingTraceRepository: getRoutingTraceRepository(),
|
||||
assignmentReasonRepository: new AssignmentReasonRepository(prisma),
|
||||
});
|
||||
}
|
||||
|
||||
export { RoutingTraceService };
|
||||
@@ -0,0 +1,8 @@
|
||||
export const ROUTING_TRACE_DI_TOKENS = {
|
||||
ROUTING_TRACE_SERVICE: Symbol("RoutingTraceService"),
|
||||
ROUTING_TRACE_SERVICE_MODULE: Symbol("RoutingTraceServiceModule"),
|
||||
PENDING_ROUTING_TRACE_REPOSITORY: Symbol("PendingRoutingTraceRepository"),
|
||||
PENDING_ROUTING_TRACE_REPOSITORY_MODULE: Symbol("PendingRoutingTraceRepositoryModule"),
|
||||
ROUTING_TRACE_REPOSITORY: Symbol("RoutingTraceRepository"),
|
||||
ROUTING_TRACE_REPOSITORY_MODULE: Symbol("RoutingTraceRepositoryModule"),
|
||||
};
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import type {
|
||||
BaseRoutingTrace,
|
||||
RoutingTrace,
|
||||
} from "./RoutingTraceRepository.interface";
|
||||
|
||||
export interface RoutingTraceWithQueuedResponse extends BaseRoutingTrace {
|
||||
queuedFormResponseId: string;
|
||||
}
|
||||
|
||||
export interface PendingRoutingTraceRecord {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
trace: RoutingTrace;
|
||||
formResponseId: number | null;
|
||||
queuedFormResponseId: string | null;
|
||||
}
|
||||
|
||||
export interface IPendingRoutingTraceRepository {
|
||||
create(args: IPendingRoutingTraceRepositoryCreateArgs): Promise<void>;
|
||||
findByFormResponseId(formResponseId: number): Promise<PendingRoutingTraceRecord | null>;
|
||||
findByQueuedFormResponseId(queuedFormResponseId: string): Promise<PendingRoutingTraceRecord | null>;
|
||||
/** Link a pending trace (created with queuedFormResponseId) to a formResponseId when the queued response is processed */
|
||||
linkToFormResponse(args: {
|
||||
queuedFormResponseId: string;
|
||||
formResponseId: number;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
interface BaseCreateArgs {
|
||||
trace: RoutingTrace;
|
||||
}
|
||||
|
||||
interface CreateWithFormResponse extends BaseCreateArgs {
|
||||
formResponseId: number;
|
||||
}
|
||||
|
||||
interface CreateWithQueuedFormResponse extends BaseCreateArgs {
|
||||
queuedFormResponseId: string;
|
||||
}
|
||||
|
||||
export type IPendingRoutingTraceRepositoryCreateArgs =
|
||||
| CreateWithFormResponse
|
||||
| CreateWithQueuedFormResponse;
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { PrismaPendingRoutingTraceRepository } from "./PrismaPendingRoutingTraceRepository";
|
||||
|
||||
describe("PrismaPendingRoutingTraceRepository", () => {
|
||||
let repository: PrismaPendingRoutingTraceRepository;
|
||||
|
||||
const mockPrisma = {
|
||||
pendingRoutingTrace: {
|
||||
create: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
} as unknown as PrismaClient;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
repository = new PrismaPendingRoutingTraceRepository(mockPrisma);
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("should create a pending routing trace with formResponseId", async () => {
|
||||
const trace = [
|
||||
{
|
||||
domain: "routing_form",
|
||||
step: "attribute-logic-evaluated",
|
||||
timestamp: Date.now(),
|
||||
data: { routeIsFallback: false },
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(mockPrisma.pendingRoutingTrace.create).mockResolvedValue({
|
||||
id: "pending-1",
|
||||
createdAt: new Date(),
|
||||
trace,
|
||||
formResponseId: 123,
|
||||
queuedFormResponseId: null,
|
||||
});
|
||||
|
||||
await repository.create({
|
||||
trace,
|
||||
formResponseId: 123,
|
||||
});
|
||||
|
||||
expect(mockPrisma.pendingRoutingTrace.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
createdAt: expect.any(Date),
|
||||
trace,
|
||||
formResponseId: 123,
|
||||
queuedFormResponseId: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should create a pending routing trace with queuedFormResponseId", async () => {
|
||||
const trace = [
|
||||
{
|
||||
domain: "routing_form",
|
||||
step: "attribute-logic-evaluated",
|
||||
timestamp: Date.now(),
|
||||
data: { routeIsFallback: true },
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(mockPrisma.pendingRoutingTrace.create).mockResolvedValue({
|
||||
id: "pending-2",
|
||||
createdAt: new Date(),
|
||||
trace,
|
||||
formResponseId: null,
|
||||
queuedFormResponseId: "queued-123",
|
||||
});
|
||||
|
||||
await repository.create({
|
||||
trace,
|
||||
queuedFormResponseId: "queued-123",
|
||||
});
|
||||
|
||||
expect(mockPrisma.pendingRoutingTrace.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
createdAt: expect.any(Date),
|
||||
trace,
|
||||
formResponseId: undefined,
|
||||
queuedFormResponseId: "queued-123",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should create a pending routing trace with empty trace array", async () => {
|
||||
const trace: never[] = [];
|
||||
|
||||
vi.mocked(mockPrisma.pendingRoutingTrace.create).mockResolvedValue({
|
||||
id: "pending-3",
|
||||
createdAt: new Date(),
|
||||
trace,
|
||||
formResponseId: 456,
|
||||
queuedFormResponseId: null,
|
||||
});
|
||||
|
||||
await repository.create({
|
||||
trace,
|
||||
formResponseId: 456,
|
||||
});
|
||||
|
||||
expect(mockPrisma.pendingRoutingTrace.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
createdAt: expect.any(Date),
|
||||
trace,
|
||||
formResponseId: 456,
|
||||
queuedFormResponseId: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("findByFormResponseId", () => {
|
||||
it("should find a pending routing trace by formResponseId", async () => {
|
||||
const trace = [
|
||||
{
|
||||
domain: "routing_form",
|
||||
step: "attribute-logic-evaluated",
|
||||
timestamp: Date.now(),
|
||||
data: { routeIsFallback: false },
|
||||
},
|
||||
];
|
||||
|
||||
const mockResult = {
|
||||
id: "pending-1",
|
||||
createdAt: new Date("2025-01-01T10:00:00Z"),
|
||||
trace,
|
||||
formResponseId: 123,
|
||||
queuedFormResponseId: null,
|
||||
};
|
||||
|
||||
vi.mocked(mockPrisma.pendingRoutingTrace.findUnique).mockResolvedValue(mockResult);
|
||||
|
||||
const result = await repository.findByFormResponseId(123);
|
||||
|
||||
expect(mockPrisma.pendingRoutingTrace.findUnique).toHaveBeenCalledWith({
|
||||
where: { formResponseId: 123 },
|
||||
});
|
||||
expect(result).toEqual({
|
||||
id: "pending-1",
|
||||
createdAt: new Date("2025-01-01T10:00:00Z"),
|
||||
trace,
|
||||
formResponseId: 123,
|
||||
queuedFormResponseId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should return null when no pending routing trace is found", async () => {
|
||||
vi.mocked(mockPrisma.pendingRoutingTrace.findUnique).mockResolvedValue(null);
|
||||
|
||||
const result = await repository.findByFormResponseId(999);
|
||||
|
||||
expect(mockPrisma.pendingRoutingTrace.findUnique).toHaveBeenCalledWith({
|
||||
where: { formResponseId: 999 },
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("findByQueuedFormResponseId", () => {
|
||||
it("should find a pending routing trace by queuedFormResponseId", async () => {
|
||||
const trace = [
|
||||
{
|
||||
domain: "salesforce",
|
||||
step: "salesforce_assignment",
|
||||
timestamp: Date.now(),
|
||||
data: { email: "owner@example.com" },
|
||||
},
|
||||
];
|
||||
|
||||
const mockResult = {
|
||||
id: "pending-2",
|
||||
createdAt: new Date("2025-01-02T10:00:00Z"),
|
||||
trace,
|
||||
formResponseId: null,
|
||||
queuedFormResponseId: "queued-456",
|
||||
};
|
||||
|
||||
vi.mocked(mockPrisma.pendingRoutingTrace.findUnique).mockResolvedValue(mockResult);
|
||||
|
||||
const result = await repository.findByQueuedFormResponseId("queued-456");
|
||||
|
||||
expect(mockPrisma.pendingRoutingTrace.findUnique).toHaveBeenCalledWith({
|
||||
where: { queuedFormResponseId: "queued-456" },
|
||||
});
|
||||
expect(result).toEqual({
|
||||
id: "pending-2",
|
||||
createdAt: new Date("2025-01-02T10:00:00Z"),
|
||||
trace,
|
||||
formResponseId: null,
|
||||
queuedFormResponseId: "queued-456",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return null when no pending routing trace is found by queuedFormResponseId", async () => {
|
||||
vi.mocked(mockPrisma.pendingRoutingTrace.findUnique).mockResolvedValue(null);
|
||||
|
||||
const result = await repository.findByQueuedFormResponseId("non-existent");
|
||||
|
||||
expect(mockPrisma.pendingRoutingTrace.findUnique).toHaveBeenCalledWith({
|
||||
where: { queuedFormResponseId: "non-existent" },
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
import type { RoutingTrace } from "./RoutingTraceRepository.interface";
|
||||
import type {
|
||||
IPendingRoutingTraceRepository,
|
||||
IPendingRoutingTraceRepositoryCreateArgs,
|
||||
PendingRoutingTraceRecord,
|
||||
} from "./PendingRoutingTraceRepository.interface";
|
||||
|
||||
export class PrismaPendingRoutingTraceRepository
|
||||
implements IPendingRoutingTraceRepository
|
||||
{
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
|
||||
async create(args: IPendingRoutingTraceRepositoryCreateArgs): Promise<void> {
|
||||
await this.prisma.pendingRoutingTrace.create({
|
||||
data: {
|
||||
createdAt: new Date(),
|
||||
trace: args.trace as Prisma.InputJsonValue,
|
||||
formResponseId:
|
||||
"formResponseId" in args ? args.formResponseId : undefined,
|
||||
queuedFormResponseId:
|
||||
"queuedFormResponseId" in args
|
||||
? args.queuedFormResponseId
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findByFormResponseId(formResponseId: number): Promise<PendingRoutingTraceRecord | null> {
|
||||
const result = await this.prisma.pendingRoutingTrace.findUnique({
|
||||
where: { formResponseId },
|
||||
});
|
||||
if (!result) return null;
|
||||
return {
|
||||
...result,
|
||||
trace: result.trace as RoutingTrace,
|
||||
};
|
||||
}
|
||||
|
||||
async findByQueuedFormResponseId(
|
||||
queuedFormResponseId: string
|
||||
): Promise<PendingRoutingTraceRecord | null> {
|
||||
const result = await this.prisma.pendingRoutingTrace.findUnique({
|
||||
where: { queuedFormResponseId },
|
||||
});
|
||||
if (!result) return null;
|
||||
return {
|
||||
...result,
|
||||
trace: result.trace as RoutingTrace,
|
||||
};
|
||||
}
|
||||
|
||||
async linkToFormResponse(args: {
|
||||
queuedFormResponseId: string;
|
||||
formResponseId: number;
|
||||
}): Promise<void> {
|
||||
await this.prisma.pendingRoutingTrace.update({
|
||||
where: { queuedFormResponseId: args.queuedFormResponseId },
|
||||
data: { formResponseId: args.formResponseId },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { PrismaRoutingTraceRepository } from "./PrismaRoutingTraceRepository";
|
||||
|
||||
describe("PrismaRoutingTraceRepository", () => {
|
||||
let repository: PrismaRoutingTraceRepository;
|
||||
|
||||
const mockPrisma = {
|
||||
routingTrace: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
} as unknown as PrismaClient;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
repository = new PrismaRoutingTraceRepository(mockPrisma);
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("should create a routing trace with all fields", async () => {
|
||||
const trace = [
|
||||
{
|
||||
domain: "routing_form",
|
||||
step: "attribute-logic-evaluated",
|
||||
timestamp: Date.now(),
|
||||
data: { routeIsFallback: false },
|
||||
},
|
||||
];
|
||||
|
||||
const mockResult = {
|
||||
id: "trace-1",
|
||||
createdAt: new Date("2025-01-01T10:00:00Z"),
|
||||
trace,
|
||||
formResponseId: 123,
|
||||
queuedFormResponseId: null,
|
||||
bookingUid: "booking-uid-123",
|
||||
assignmentReasonId: 42,
|
||||
};
|
||||
|
||||
vi.mocked(mockPrisma.routingTrace.create).mockResolvedValue(mockResult);
|
||||
|
||||
const result = await repository.create({
|
||||
trace,
|
||||
formResponseId: 123,
|
||||
bookingUid: "booking-uid-123",
|
||||
assignmentReasonId: 42,
|
||||
});
|
||||
|
||||
expect(mockPrisma.routingTrace.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
createdAt: expect.any(Date),
|
||||
trace,
|
||||
formResponseId: 123,
|
||||
queuedFormResponseId: undefined,
|
||||
bookingUid: "booking-uid-123",
|
||||
assignmentReasonId: 42,
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({
|
||||
id: "trace-1",
|
||||
createdAt: new Date("2025-01-01T10:00:00Z"),
|
||||
trace,
|
||||
formResponseId: 123,
|
||||
queuedFormResponseId: null,
|
||||
bookingUid: "booking-uid-123",
|
||||
assignmentReasonId: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it("should create a routing trace with queuedFormResponseId", async () => {
|
||||
const trace = [
|
||||
{
|
||||
domain: "salesforce",
|
||||
step: "salesforce_assignment",
|
||||
timestamp: Date.now(),
|
||||
data: { email: "owner@example.com" },
|
||||
},
|
||||
];
|
||||
|
||||
const mockResult = {
|
||||
id: "trace-2",
|
||||
createdAt: new Date("2025-01-02T10:00:00Z"),
|
||||
trace,
|
||||
formResponseId: null,
|
||||
queuedFormResponseId: "queued-456",
|
||||
bookingUid: "booking-uid-456",
|
||||
assignmentReasonId: 100,
|
||||
};
|
||||
|
||||
vi.mocked(mockPrisma.routingTrace.create).mockResolvedValue(mockResult);
|
||||
|
||||
const result = await repository.create({
|
||||
trace,
|
||||
queuedFormResponseId: "queued-456",
|
||||
bookingUid: "booking-uid-456",
|
||||
assignmentReasonId: 100,
|
||||
});
|
||||
|
||||
expect(mockPrisma.routingTrace.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
createdAt: expect.any(Date),
|
||||
trace,
|
||||
formResponseId: undefined,
|
||||
queuedFormResponseId: "queued-456",
|
||||
bookingUid: "booking-uid-456",
|
||||
assignmentReasonId: 100,
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({
|
||||
id: "trace-2",
|
||||
createdAt: new Date("2025-01-02T10:00:00Z"),
|
||||
trace,
|
||||
formResponseId: null,
|
||||
queuedFormResponseId: "queued-456",
|
||||
bookingUid: "booking-uid-456",
|
||||
assignmentReasonId: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it("should create a routing trace without assignmentReasonId", async () => {
|
||||
const trace = [
|
||||
{
|
||||
domain: "other_domain",
|
||||
step: "some_step",
|
||||
timestamp: Date.now(),
|
||||
data: {},
|
||||
},
|
||||
];
|
||||
|
||||
const mockResult = {
|
||||
id: "trace-3",
|
||||
createdAt: new Date("2025-01-03T10:00:00Z"),
|
||||
trace,
|
||||
formResponseId: 789,
|
||||
queuedFormResponseId: null,
|
||||
bookingUid: "booking-uid-789",
|
||||
assignmentReasonId: null,
|
||||
};
|
||||
|
||||
vi.mocked(mockPrisma.routingTrace.create).mockResolvedValue(mockResult);
|
||||
|
||||
const result = await repository.create({
|
||||
trace,
|
||||
formResponseId: 789,
|
||||
bookingUid: "booking-uid-789",
|
||||
});
|
||||
|
||||
expect(mockPrisma.routingTrace.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
createdAt: expect.any(Date),
|
||||
trace,
|
||||
formResponseId: 789,
|
||||
queuedFormResponseId: undefined,
|
||||
bookingUid: "booking-uid-789",
|
||||
assignmentReasonId: undefined,
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({
|
||||
id: "trace-3",
|
||||
createdAt: new Date("2025-01-03T10:00:00Z"),
|
||||
trace,
|
||||
formResponseId: 789,
|
||||
queuedFormResponseId: null,
|
||||
bookingUid: "booking-uid-789",
|
||||
assignmentReasonId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should create a routing trace with multiple trace steps", async () => {
|
||||
const trace = [
|
||||
{
|
||||
domain: "salesforce",
|
||||
step: "salesforce_assignment",
|
||||
timestamp: Date.now() - 1000,
|
||||
data: { email: "owner@example.com", recordType: "Contact", recordId: "003ABC" },
|
||||
},
|
||||
{
|
||||
domain: "routing_form",
|
||||
step: "attribute-logic-evaluated",
|
||||
timestamp: Date.now(),
|
||||
data: { routeIsFallback: false },
|
||||
},
|
||||
];
|
||||
|
||||
const mockResult = {
|
||||
id: "trace-4",
|
||||
createdAt: new Date("2025-01-04T10:00:00Z"),
|
||||
trace,
|
||||
formResponseId: 111,
|
||||
queuedFormResponseId: null,
|
||||
bookingUid: "booking-uid-111",
|
||||
assignmentReasonId: 50,
|
||||
};
|
||||
|
||||
vi.mocked(mockPrisma.routingTrace.create).mockResolvedValue(mockResult);
|
||||
|
||||
const result = await repository.create({
|
||||
trace,
|
||||
formResponseId: 111,
|
||||
bookingUid: "booking-uid-111",
|
||||
assignmentReasonId: 50,
|
||||
});
|
||||
|
||||
expect(mockPrisma.routingTrace.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
createdAt: expect.any(Date),
|
||||
trace,
|
||||
formResponseId: 111,
|
||||
queuedFormResponseId: undefined,
|
||||
bookingUid: "booking-uid-111",
|
||||
assignmentReasonId: 50,
|
||||
},
|
||||
});
|
||||
expect(result.trace).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should create a routing trace with empty trace array", async () => {
|
||||
const trace: never[] = [];
|
||||
|
||||
const mockResult = {
|
||||
id: "trace-5",
|
||||
createdAt: new Date("2025-01-05T10:00:00Z"),
|
||||
trace,
|
||||
formResponseId: 222,
|
||||
queuedFormResponseId: null,
|
||||
bookingUid: "booking-uid-222",
|
||||
assignmentReasonId: null,
|
||||
};
|
||||
|
||||
vi.mocked(mockPrisma.routingTrace.create).mockResolvedValue(mockResult);
|
||||
|
||||
const result = await repository.create({
|
||||
trace,
|
||||
formResponseId: 222,
|
||||
bookingUid: "booking-uid-222",
|
||||
});
|
||||
|
||||
expect(mockPrisma.routingTrace.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
createdAt: expect.any(Date),
|
||||
trace,
|
||||
formResponseId: 222,
|
||||
queuedFormResponseId: undefined,
|
||||
bookingUid: "booking-uid-222",
|
||||
assignmentReasonId: undefined,
|
||||
},
|
||||
});
|
||||
expect(result.trace).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
import type {
|
||||
IRoutingTraceRepository,
|
||||
IRoutingTraceRepositoryCreateArgs,
|
||||
RoutingTrace,
|
||||
RoutingTraceRecord,
|
||||
} from "./RoutingTraceRepository.interface";
|
||||
|
||||
export class PrismaRoutingTraceRepository implements IRoutingTraceRepository {
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
|
||||
async create(args: IRoutingTraceRepositoryCreateArgs): Promise<RoutingTraceRecord> {
|
||||
const result = await this.prisma.routingTrace.create({
|
||||
data: {
|
||||
createdAt: new Date(),
|
||||
trace: args.trace as Prisma.InputJsonValue,
|
||||
formResponseId: args.formResponseId,
|
||||
queuedFormResponseId: args.queuedFormResponseId,
|
||||
bookingUid: args.bookingUid,
|
||||
assignmentReasonId: args.assignmentReasonId,
|
||||
},
|
||||
});
|
||||
return {
|
||||
...result,
|
||||
trace: result.trace as RoutingTrace,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import z from "zod";
|
||||
import type { routingStepSchema } from "../schema/zod";
|
||||
|
||||
export type RoutingStep = z.infer<typeof routingStepSchema>;
|
||||
|
||||
export type RoutingTrace = RoutingStep[];
|
||||
|
||||
export interface BaseRoutingTrace {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
trace: string;
|
||||
}
|
||||
|
||||
export interface RoutingTraceWithFormResponse extends BaseRoutingTrace {
|
||||
formResponseId: string;
|
||||
}
|
||||
|
||||
export interface RoutingTraceRecord {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
trace: RoutingTrace;
|
||||
formResponseId: number | null;
|
||||
queuedFormResponseId: string | null;
|
||||
bookingUid: string | null;
|
||||
assignmentReasonId: number | null;
|
||||
}
|
||||
|
||||
export interface IRoutingTraceRepositoryCreateArgs {
|
||||
trace: RoutingTrace;
|
||||
formResponseId?: number;
|
||||
queuedFormResponseId?: string;
|
||||
bookingUid: string;
|
||||
assignmentReasonId?: number;
|
||||
}
|
||||
|
||||
export interface IRoutingTraceRepository {
|
||||
create(args: IRoutingTraceRepositoryCreateArgs): Promise<RoutingTraceRecord>;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import z from "zod";
|
||||
|
||||
export const routingStepSchema = z.object({
|
||||
domain: z.string(),
|
||||
step: z.string(),
|
||||
timestamp: z.number(),
|
||||
data: z.record(z.unknown()),
|
||||
});
|
||||
@@ -0,0 +1,663 @@
|
||||
import type { AssignmentReasonRepository } from "@calcom/features/assignment-reason/repositories/AssignmentReasonRepository";
|
||||
import { AssignmentReasonEnum } from "@calcom/prisma/enums";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type {
|
||||
IPendingRoutingTraceRepository,
|
||||
PendingRoutingTraceRecord,
|
||||
} from "../repositories/PendingRoutingTraceRepository.interface";
|
||||
import type { IRoutingTraceRepository } from "../repositories/RoutingTraceRepository.interface";
|
||||
import { RoutingTraceService } from "./RoutingTraceService";
|
||||
|
||||
describe("RoutingTraceService", () => {
|
||||
let service: RoutingTraceService;
|
||||
let mockPendingRoutingTraceRepository: IPendingRoutingTraceRepository;
|
||||
let mockRoutingTraceRepository: IRoutingTraceRepository;
|
||||
let mockAssignmentReasonRepository: Pick<AssignmentReasonRepository, "createAssignmentReason">;
|
||||
|
||||
function createMockPendingRoutingTraceRepository(): IPendingRoutingTraceRepository {
|
||||
return {
|
||||
create: vi.fn(),
|
||||
findByFormResponseId: vi.fn(),
|
||||
findByQueuedFormResponseId: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function createMockRoutingTraceRepository(): IRoutingTraceRepository {
|
||||
return {
|
||||
create: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function createMockAssignmentReasonRepository(): Pick<AssignmentReasonRepository, "createAssignmentReason"> {
|
||||
return {
|
||||
createAssignmentReason: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockPendingRoutingTraceRepository = createMockPendingRoutingTraceRepository();
|
||||
mockRoutingTraceRepository = createMockRoutingTraceRepository();
|
||||
mockAssignmentReasonRepository = createMockAssignmentReasonRepository();
|
||||
service = new RoutingTraceService({
|
||||
pendingRoutingTraceRepository: mockPendingRoutingTraceRepository,
|
||||
routingTraceRepository: mockRoutingTraceRepository,
|
||||
assignmentReasonRepository: mockAssignmentReasonRepository as AssignmentReasonRepository,
|
||||
});
|
||||
});
|
||||
|
||||
describe("addStep", () => {
|
||||
it("should add a step to the routing trace", () => {
|
||||
service.addStep({
|
||||
domain: "routing_form",
|
||||
step: "attribute-logic-evaluated",
|
||||
data: { routeIsFallback: false },
|
||||
});
|
||||
|
||||
expect(service.getStepsCount()).toBe(1);
|
||||
});
|
||||
|
||||
it("should add multiple steps to the routing trace", () => {
|
||||
service.addStep({
|
||||
domain: "routing_form",
|
||||
step: "attribute-logic-evaluated",
|
||||
data: { routeIsFallback: false },
|
||||
});
|
||||
|
||||
service.addStep({
|
||||
domain: "salesforce",
|
||||
step: "salesforce_assignment",
|
||||
data: { email: "owner@example.com" },
|
||||
});
|
||||
|
||||
expect(service.getStepsCount()).toBe(2);
|
||||
});
|
||||
|
||||
it("should add step with empty data by default", () => {
|
||||
service.addStep({
|
||||
domain: "test_domain",
|
||||
step: "test_step",
|
||||
});
|
||||
|
||||
expect(service.getStepsCount()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getStepsCount", () => {
|
||||
it("should return 0 when no steps have been added", () => {
|
||||
expect(service.getStepsCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("should return correct count after adding steps", () => {
|
||||
service.addStep({ domain: "test", step: "step1" });
|
||||
service.addStep({ domain: "test", step: "step2" });
|
||||
service.addStep({ domain: "test", step: "step3" });
|
||||
|
||||
expect(service.getStepsCount()).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("savePendingRoutingTrace", () => {
|
||||
it("should save pending trace with formResponseId", async () => {
|
||||
service.addStep({
|
||||
domain: "routing_form",
|
||||
step: "attribute-logic-evaluated",
|
||||
data: { routeIsFallback: false },
|
||||
});
|
||||
|
||||
await service.savePendingRoutingTrace({ formResponseId: 123 });
|
||||
|
||||
expect(mockPendingRoutingTraceRepository.create).toHaveBeenCalledWith({
|
||||
trace: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
domain: "routing_form",
|
||||
step: "attribute-logic-evaluated",
|
||||
data: { routeIsFallback: false },
|
||||
}),
|
||||
]),
|
||||
formResponseId: 123,
|
||||
});
|
||||
});
|
||||
|
||||
it("should save pending trace with queuedFormResponseId", async () => {
|
||||
service.addStep({
|
||||
domain: "routing_form",
|
||||
step: "attribute-logic-evaluated",
|
||||
data: { routeIsFallback: true },
|
||||
});
|
||||
|
||||
await service.savePendingRoutingTrace({ queuedFormResponseId: "queued-123" });
|
||||
|
||||
expect(mockPendingRoutingTraceRepository.create).toHaveBeenCalledWith({
|
||||
trace: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
domain: "routing_form",
|
||||
step: "attribute-logic-evaluated",
|
||||
data: { routeIsFallback: true },
|
||||
}),
|
||||
]),
|
||||
queuedFormResponseId: "queued-123",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("processForBooking", () => {
|
||||
const baseProcessArgs = {
|
||||
bookingId: 1,
|
||||
bookingUid: "booking-uid-123",
|
||||
organizerEmail: "organizer@example.com",
|
||||
isRerouting: false,
|
||||
};
|
||||
|
||||
it("should return null if no pending trace is found", async () => {
|
||||
vi.mocked(mockPendingRoutingTraceRepository.findByFormResponseId).mockResolvedValue(null);
|
||||
|
||||
const result = await service.processForBooking({
|
||||
...baseProcessArgs,
|
||||
formResponseId: 123,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null if no pending trace is found for queuedFormResponseId", async () => {
|
||||
vi.mocked(mockPendingRoutingTraceRepository.findByQueuedFormResponseId).mockResolvedValue(null);
|
||||
|
||||
const result = await service.processForBooking({
|
||||
...baseProcessArgs,
|
||||
queuedFormResponseId: "queued-123",
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
describe("routing form assignment", () => {
|
||||
it("should extract ROUTING_FORM_ROUTING assignment reason", async () => {
|
||||
const pendingTrace: PendingRoutingTraceRecord = {
|
||||
id: "pending-1",
|
||||
createdAt: new Date(),
|
||||
trace: [
|
||||
{
|
||||
domain: "routing_form",
|
||||
step: "attribute-logic-evaluated",
|
||||
timestamp: Date.now(),
|
||||
data: { routeIsFallback: false },
|
||||
},
|
||||
],
|
||||
formResponseId: 123,
|
||||
queuedFormResponseId: null,
|
||||
};
|
||||
|
||||
vi.mocked(mockPendingRoutingTraceRepository.findByFormResponseId).mockResolvedValue(pendingTrace);
|
||||
vi.mocked(mockAssignmentReasonRepository.createAssignmentReason).mockResolvedValue({
|
||||
id: 1,
|
||||
bookingId: 1,
|
||||
reasonEnum: AssignmentReasonEnum.ROUTING_FORM_ROUTING,
|
||||
reasonString: "",
|
||||
});
|
||||
vi.mocked(mockRoutingTraceRepository.create).mockResolvedValue({
|
||||
id: "trace-1",
|
||||
createdAt: new Date(),
|
||||
trace: pendingTrace.trace,
|
||||
formResponseId: 123,
|
||||
queuedFormResponseId: null,
|
||||
bookingUid: "booking-uid-123",
|
||||
assignmentReasonId: 1,
|
||||
});
|
||||
|
||||
const result = await service.processForBooking({
|
||||
...baseProcessArgs,
|
||||
formResponseId: 123,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
assignmentReason: {
|
||||
reasonEnum: AssignmentReasonEnum.ROUTING_FORM_ROUTING,
|
||||
reasonString: "",
|
||||
},
|
||||
});
|
||||
expect(mockAssignmentReasonRepository.createAssignmentReason).toHaveBeenCalledWith({
|
||||
bookingId: 1,
|
||||
reasonEnum: AssignmentReasonEnum.ROUTING_FORM_ROUTING,
|
||||
reasonString: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("should extract ROUTING_FORM_ROUTING_FALLBACK assignment reason when routeIsFallback is true", async () => {
|
||||
const pendingTrace: PendingRoutingTraceRecord = {
|
||||
id: "pending-1",
|
||||
createdAt: new Date(),
|
||||
trace: [
|
||||
{
|
||||
domain: "routing_form",
|
||||
step: "attribute-logic-evaluated",
|
||||
timestamp: Date.now(),
|
||||
data: { routeIsFallback: true },
|
||||
},
|
||||
],
|
||||
formResponseId: 123,
|
||||
queuedFormResponseId: null,
|
||||
};
|
||||
|
||||
vi.mocked(mockPendingRoutingTraceRepository.findByFormResponseId).mockResolvedValue(pendingTrace);
|
||||
vi.mocked(mockAssignmentReasonRepository.createAssignmentReason).mockResolvedValue({
|
||||
id: 1,
|
||||
bookingId: 1,
|
||||
reasonEnum: AssignmentReasonEnum.ROUTING_FORM_ROUTING_FALLBACK,
|
||||
reasonString: "",
|
||||
});
|
||||
vi.mocked(mockRoutingTraceRepository.create).mockResolvedValue({
|
||||
id: "trace-1",
|
||||
createdAt: new Date(),
|
||||
trace: pendingTrace.trace,
|
||||
formResponseId: 123,
|
||||
queuedFormResponseId: null,
|
||||
bookingUid: "booking-uid-123",
|
||||
assignmentReasonId: 1,
|
||||
});
|
||||
|
||||
const result = await service.processForBooking({
|
||||
...baseProcessArgs,
|
||||
formResponseId: 123,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
assignmentReason: {
|
||||
reasonEnum: AssignmentReasonEnum.ROUTING_FORM_ROUTING_FALLBACK,
|
||||
reasonString: "",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should extract REROUTED assignment reason when isRerouting is true", async () => {
|
||||
const pendingTrace: PendingRoutingTraceRecord = {
|
||||
id: "pending-1",
|
||||
createdAt: new Date(),
|
||||
trace: [
|
||||
{
|
||||
domain: "routing_form",
|
||||
step: "attribute-logic-evaluated",
|
||||
timestamp: Date.now(),
|
||||
data: { routeIsFallback: false },
|
||||
},
|
||||
],
|
||||
formResponseId: 123,
|
||||
queuedFormResponseId: null,
|
||||
};
|
||||
|
||||
vi.mocked(mockPendingRoutingTraceRepository.findByFormResponseId).mockResolvedValue(pendingTrace);
|
||||
vi.mocked(mockAssignmentReasonRepository.createAssignmentReason).mockResolvedValue({
|
||||
id: 1,
|
||||
bookingId: 1,
|
||||
reasonEnum: AssignmentReasonEnum.REROUTED,
|
||||
reasonString: "Rerouted by admin@example.com",
|
||||
});
|
||||
vi.mocked(mockRoutingTraceRepository.create).mockResolvedValue({
|
||||
id: "trace-1",
|
||||
createdAt: new Date(),
|
||||
trace: pendingTrace.trace,
|
||||
formResponseId: 123,
|
||||
queuedFormResponseId: null,
|
||||
bookingUid: "booking-uid-123",
|
||||
assignmentReasonId: 1,
|
||||
});
|
||||
|
||||
const result = await service.processForBooking({
|
||||
...baseProcessArgs,
|
||||
formResponseId: 123,
|
||||
isRerouting: true,
|
||||
reroutedByEmail: "admin@example.com",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
assignmentReason: {
|
||||
reasonEnum: AssignmentReasonEnum.REROUTED,
|
||||
reasonString: "Rerouted by admin@example.com",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("salesforce assignment", () => {
|
||||
it("should extract SALESFORCE_ASSIGNMENT when organizer matches CRM contact owner", async () => {
|
||||
const pendingTrace: PendingRoutingTraceRecord = {
|
||||
id: "pending-1",
|
||||
createdAt: new Date(),
|
||||
trace: [
|
||||
{
|
||||
domain: "salesforce",
|
||||
step: "salesforce_assignment",
|
||||
timestamp: Date.now(),
|
||||
data: {
|
||||
email: "organizer@example.com",
|
||||
recordType: "Contact",
|
||||
recordId: "003ABC123",
|
||||
},
|
||||
},
|
||||
],
|
||||
formResponseId: 123,
|
||||
queuedFormResponseId: null,
|
||||
};
|
||||
|
||||
vi.mocked(mockPendingRoutingTraceRepository.findByFormResponseId).mockResolvedValue(pendingTrace);
|
||||
vi.mocked(mockAssignmentReasonRepository.createAssignmentReason).mockResolvedValue({
|
||||
id: 1,
|
||||
bookingId: 1,
|
||||
reasonEnum: AssignmentReasonEnum.SALESFORCE_ASSIGNMENT,
|
||||
reasonString: "Salesforce contact owner: organizer@example.com (Contact ID: 003ABC123)",
|
||||
});
|
||||
vi.mocked(mockRoutingTraceRepository.create).mockResolvedValue({
|
||||
id: "trace-1",
|
||||
createdAt: new Date(),
|
||||
trace: pendingTrace.trace,
|
||||
formResponseId: 123,
|
||||
queuedFormResponseId: null,
|
||||
bookingUid: "booking-uid-123",
|
||||
assignmentReasonId: 1,
|
||||
});
|
||||
|
||||
const result = await service.processForBooking({
|
||||
...baseProcessArgs,
|
||||
formResponseId: 123,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
assignmentReason: {
|
||||
reasonEnum: AssignmentReasonEnum.SALESFORCE_ASSIGNMENT,
|
||||
reasonString: "Salesforce contact owner: organizer@example.com (Contact ID: 003ABC123)",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should use account lookup field reason string when rrSkipToAccountLookupField is true", async () => {
|
||||
const pendingTrace: PendingRoutingTraceRecord = {
|
||||
id: "pending-1",
|
||||
createdAt: new Date(),
|
||||
trace: [
|
||||
{
|
||||
domain: "salesforce",
|
||||
step: "salesforce_assignment",
|
||||
timestamp: Date.now(),
|
||||
data: {
|
||||
email: "organizer@example.com",
|
||||
recordType: "Account",
|
||||
recordId: "001ABC123",
|
||||
rrSkipToAccountLookupField: true,
|
||||
rrSKipToAccountLookupFieldName: "Account_Owner__c",
|
||||
},
|
||||
},
|
||||
],
|
||||
formResponseId: 123,
|
||||
queuedFormResponseId: null,
|
||||
};
|
||||
|
||||
vi.mocked(mockPendingRoutingTraceRepository.findByFormResponseId).mockResolvedValue(pendingTrace);
|
||||
vi.mocked(mockAssignmentReasonRepository.createAssignmentReason).mockResolvedValue({
|
||||
id: 1,
|
||||
bookingId: 1,
|
||||
reasonEnum: AssignmentReasonEnum.SALESFORCE_ASSIGNMENT,
|
||||
reasonString:
|
||||
"Salesforce account lookup field: Account_Owner__c - organizer@example.com (Account ID: 001ABC123)",
|
||||
});
|
||||
vi.mocked(mockRoutingTraceRepository.create).mockResolvedValue({
|
||||
id: "trace-1",
|
||||
createdAt: new Date(),
|
||||
trace: pendingTrace.trace,
|
||||
formResponseId: 123,
|
||||
queuedFormResponseId: null,
|
||||
bookingUid: "booking-uid-123",
|
||||
assignmentReasonId: 1,
|
||||
});
|
||||
|
||||
const result = await service.processForBooking({
|
||||
...baseProcessArgs,
|
||||
formResponseId: 123,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
assignmentReason: {
|
||||
reasonEnum: AssignmentReasonEnum.SALESFORCE_ASSIGNMENT,
|
||||
reasonString:
|
||||
"Salesforce account lookup field: Account_Owner__c - organizer@example.com (Account ID: 001ABC123)",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should fall back to routing form assignment when organizer does not match CRM contact owner", async () => {
|
||||
const pendingTrace: PendingRoutingTraceRecord = {
|
||||
id: "pending-1",
|
||||
createdAt: new Date(),
|
||||
trace: [
|
||||
{
|
||||
domain: "salesforce",
|
||||
step: "salesforce_assignment",
|
||||
timestamp: Date.now(),
|
||||
data: {
|
||||
email: "different-owner@example.com",
|
||||
recordType: "Contact",
|
||||
recordId: "003ABC123",
|
||||
},
|
||||
},
|
||||
{
|
||||
domain: "routing_form",
|
||||
step: "attribute-logic-evaluated",
|
||||
timestamp: Date.now(),
|
||||
data: { routeIsFallback: false },
|
||||
},
|
||||
],
|
||||
formResponseId: 123,
|
||||
queuedFormResponseId: null,
|
||||
};
|
||||
|
||||
vi.mocked(mockPendingRoutingTraceRepository.findByFormResponseId).mockResolvedValue(pendingTrace);
|
||||
vi.mocked(mockAssignmentReasonRepository.createAssignmentReason).mockResolvedValue({
|
||||
id: 1,
|
||||
bookingId: 1,
|
||||
reasonEnum: AssignmentReasonEnum.ROUTING_FORM_ROUTING,
|
||||
reasonString: "",
|
||||
});
|
||||
vi.mocked(mockRoutingTraceRepository.create).mockResolvedValue({
|
||||
id: "trace-1",
|
||||
createdAt: new Date(),
|
||||
trace: pendingTrace.trace,
|
||||
formResponseId: 123,
|
||||
queuedFormResponseId: null,
|
||||
bookingUid: "booking-uid-123",
|
||||
assignmentReasonId: 1,
|
||||
});
|
||||
|
||||
const result = await service.processForBooking({
|
||||
...baseProcessArgs,
|
||||
formResponseId: 123,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
assignmentReason: {
|
||||
reasonEnum: AssignmentReasonEnum.ROUTING_FORM_ROUTING,
|
||||
reasonString: "",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle case-insensitive email matching for CRM assignment", async () => {
|
||||
const pendingTrace: PendingRoutingTraceRecord = {
|
||||
id: "pending-1",
|
||||
createdAt: new Date(),
|
||||
trace: [
|
||||
{
|
||||
domain: "salesforce",
|
||||
step: "salesforce_assignment",
|
||||
timestamp: Date.now(),
|
||||
data: {
|
||||
email: "ORGANIZER@EXAMPLE.COM",
|
||||
recordType: "Contact",
|
||||
recordId: "003ABC123",
|
||||
},
|
||||
},
|
||||
],
|
||||
formResponseId: 123,
|
||||
queuedFormResponseId: null,
|
||||
};
|
||||
|
||||
vi.mocked(mockPendingRoutingTraceRepository.findByFormResponseId).mockResolvedValue(pendingTrace);
|
||||
vi.mocked(mockAssignmentReasonRepository.createAssignmentReason).mockResolvedValue({
|
||||
id: 1,
|
||||
bookingId: 1,
|
||||
reasonEnum: AssignmentReasonEnum.SALESFORCE_ASSIGNMENT,
|
||||
reasonString: "Salesforce contact owner: ORGANIZER@EXAMPLE.COM (Contact ID: 003ABC123)",
|
||||
});
|
||||
vi.mocked(mockRoutingTraceRepository.create).mockResolvedValue({
|
||||
id: "trace-1",
|
||||
createdAt: new Date(),
|
||||
trace: pendingTrace.trace,
|
||||
formResponseId: 123,
|
||||
queuedFormResponseId: null,
|
||||
bookingUid: "booking-uid-123",
|
||||
assignmentReasonId: 1,
|
||||
});
|
||||
|
||||
const result = await service.processForBooking({
|
||||
...baseProcessArgs,
|
||||
formResponseId: 123,
|
||||
});
|
||||
|
||||
expect(result?.assignmentReason?.reasonEnum).toBe(AssignmentReasonEnum.SALESFORCE_ASSIGNMENT);
|
||||
});
|
||||
});
|
||||
|
||||
it("should create permanent routing trace with assignment reason", async () => {
|
||||
const pendingTrace: PendingRoutingTraceRecord = {
|
||||
id: "pending-1",
|
||||
createdAt: new Date(),
|
||||
trace: [
|
||||
{
|
||||
domain: "routing_form",
|
||||
step: "attribute-logic-evaluated",
|
||||
timestamp: Date.now(),
|
||||
data: { routeIsFallback: false },
|
||||
},
|
||||
],
|
||||
formResponseId: 123,
|
||||
queuedFormResponseId: null,
|
||||
};
|
||||
|
||||
vi.mocked(mockPendingRoutingTraceRepository.findByFormResponseId).mockResolvedValue(pendingTrace);
|
||||
vi.mocked(mockAssignmentReasonRepository.createAssignmentReason).mockResolvedValue({
|
||||
id: 42,
|
||||
bookingId: 1,
|
||||
reasonEnum: AssignmentReasonEnum.ROUTING_FORM_ROUTING,
|
||||
reasonString: "",
|
||||
});
|
||||
vi.mocked(mockRoutingTraceRepository.create).mockResolvedValue({
|
||||
id: "trace-1",
|
||||
createdAt: new Date(),
|
||||
trace: pendingTrace.trace,
|
||||
formResponseId: 123,
|
||||
queuedFormResponseId: null,
|
||||
bookingUid: "booking-uid-123",
|
||||
assignmentReasonId: 42,
|
||||
});
|
||||
|
||||
await service.processForBooking({
|
||||
...baseProcessArgs,
|
||||
formResponseId: 123,
|
||||
});
|
||||
|
||||
expect(mockRoutingTraceRepository.create).toHaveBeenCalledWith({
|
||||
trace: pendingTrace.trace,
|
||||
formResponseId: 123,
|
||||
queuedFormResponseId: undefined,
|
||||
bookingUid: "booking-uid-123",
|
||||
assignmentReasonId: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle pending trace without assignment reason data", async () => {
|
||||
const pendingTrace: PendingRoutingTraceRecord = {
|
||||
id: "pending-1",
|
||||
createdAt: new Date(),
|
||||
trace: [
|
||||
{
|
||||
domain: "other_domain",
|
||||
step: "some_step",
|
||||
timestamp: Date.now(),
|
||||
data: {},
|
||||
},
|
||||
],
|
||||
formResponseId: 123,
|
||||
queuedFormResponseId: null,
|
||||
};
|
||||
|
||||
vi.mocked(mockPendingRoutingTraceRepository.findByFormResponseId).mockResolvedValue(pendingTrace);
|
||||
vi.mocked(mockRoutingTraceRepository.create).mockResolvedValue({
|
||||
id: "trace-1",
|
||||
createdAt: new Date(),
|
||||
trace: pendingTrace.trace,
|
||||
formResponseId: 123,
|
||||
queuedFormResponseId: null,
|
||||
bookingUid: "booking-uid-123",
|
||||
assignmentReasonId: null,
|
||||
});
|
||||
|
||||
const result = await service.processForBooking({
|
||||
...baseProcessArgs,
|
||||
formResponseId: 123,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
assignmentReason: undefined,
|
||||
});
|
||||
expect(mockAssignmentReasonRepository.createAssignmentReason).not.toHaveBeenCalled();
|
||||
expect(mockRoutingTraceRepository.create).toHaveBeenCalledWith({
|
||||
trace: pendingTrace.trace,
|
||||
formResponseId: 123,
|
||||
queuedFormResponseId: undefined,
|
||||
bookingUid: "booking-uid-123",
|
||||
assignmentReasonId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("should process pending trace found by queuedFormResponseId", async () => {
|
||||
const pendingTrace: PendingRoutingTraceRecord = {
|
||||
id: "pending-1",
|
||||
createdAt: new Date(),
|
||||
trace: [
|
||||
{
|
||||
domain: "routing_form",
|
||||
step: "attribute-logic-evaluated",
|
||||
timestamp: Date.now(),
|
||||
data: { routeIsFallback: false },
|
||||
},
|
||||
],
|
||||
formResponseId: null,
|
||||
queuedFormResponseId: "queued-123",
|
||||
};
|
||||
|
||||
vi.mocked(mockPendingRoutingTraceRepository.findByQueuedFormResponseId).mockResolvedValue(pendingTrace);
|
||||
vi.mocked(mockAssignmentReasonRepository.createAssignmentReason).mockResolvedValue({
|
||||
id: 1,
|
||||
bookingId: 1,
|
||||
reasonEnum: AssignmentReasonEnum.ROUTING_FORM_ROUTING,
|
||||
reasonString: "",
|
||||
});
|
||||
vi.mocked(mockRoutingTraceRepository.create).mockResolvedValue({
|
||||
id: "trace-1",
|
||||
createdAt: new Date(),
|
||||
trace: pendingTrace.trace,
|
||||
formResponseId: null,
|
||||
queuedFormResponseId: "queued-123",
|
||||
bookingUid: "booking-uid-123",
|
||||
assignmentReasonId: 1,
|
||||
});
|
||||
|
||||
const result = await service.processForBooking({
|
||||
...baseProcessArgs,
|
||||
queuedFormResponseId: "queued-123",
|
||||
});
|
||||
|
||||
expect(mockPendingRoutingTraceRepository.findByQueuedFormResponseId).toHaveBeenCalledWith("queued-123");
|
||||
expect(result?.assignmentReason?.reasonEnum).toBe(AssignmentReasonEnum.ROUTING_FORM_ROUTING);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,269 @@
|
||||
import type { AssignmentReasonRepository } from "@calcom/features/assignment-reason/repositories/AssignmentReasonRepository";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { AssignmentReasonEnum } from "@calcom/prisma/enums";
|
||||
|
||||
import type { IPendingRoutingTraceRepository } from "../repositories/PendingRoutingTraceRepository.interface";
|
||||
import type {
|
||||
IRoutingTraceRepository,
|
||||
RoutingStep,
|
||||
RoutingTrace,
|
||||
} from "../repositories/RoutingTraceRepository.interface";
|
||||
|
||||
export const ROUTING_TRACE_DOMAINS = {
|
||||
SALESFORCE: "salesforce",
|
||||
ROUTING_FORM: "routing_form",
|
||||
} as const;
|
||||
|
||||
export const ROUTING_TRACE_STEPS = {
|
||||
SALESFORCE_ASSIGNMENT: "salesforce_assignment",
|
||||
ATTRIBUTE_LOGIC_EVALUATED: "attribute-logic-evaluated",
|
||||
} as const;
|
||||
|
||||
interface IRoutingTraceServiceDeps {
|
||||
pendingRoutingTraceRepository: IPendingRoutingTraceRepository;
|
||||
routingTraceRepository: IRoutingTraceRepository;
|
||||
assignmentReasonRepository: AssignmentReasonRepository;
|
||||
}
|
||||
|
||||
export interface ProcessRoutingTraceResult {
|
||||
assignmentReason?: {
|
||||
reasonEnum: AssignmentReasonEnum;
|
||||
reasonString: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class RoutingTraceService {
|
||||
private routingTraceSteps: RoutingStep[] = [];
|
||||
|
||||
constructor(private readonly deps: IRoutingTraceServiceDeps) {}
|
||||
|
||||
/** For debugging - get the number of steps recorded */
|
||||
getStepsCount(): number {
|
||||
return this.routingTraceSteps.length;
|
||||
}
|
||||
|
||||
/** To be called by the domain specific routing trace services */
|
||||
addStep({
|
||||
domain,
|
||||
step,
|
||||
data = {},
|
||||
}: {
|
||||
domain: string;
|
||||
step: string;
|
||||
data?: Record<string, unknown>;
|
||||
}) {
|
||||
this.routingTraceSteps.push({ domain, step, timestamp: Date.now(), data });
|
||||
}
|
||||
|
||||
/** Save pending trace when routing form is submitted (before booking is created) */
|
||||
async savePendingRoutingTrace(
|
||||
args: { formResponseId: number } | { queuedFormResponseId: string }
|
||||
) {
|
||||
await this.deps.pendingRoutingTraceRepository.create({
|
||||
trace: this.routingTraceSteps,
|
||||
...args,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process pending routing trace for a booking.
|
||||
* Looks up the pending trace, extracts assignment reason, creates permanent trace.
|
||||
*/
|
||||
async processForBooking(args: {
|
||||
formResponseId?: number;
|
||||
queuedFormResponseId?: string;
|
||||
bookingId: number;
|
||||
bookingUid: string;
|
||||
organizerEmail: string;
|
||||
isRerouting: boolean;
|
||||
reroutedByEmail?: string | null;
|
||||
}): Promise<ProcessRoutingTraceResult | null> {
|
||||
const { formResponseId, queuedFormResponseId, bookingId, bookingUid, organizerEmail, isRerouting, reroutedByEmail } = args;
|
||||
|
||||
// 1. Look up pending trace by formResponseId or queuedFormResponseId
|
||||
let pendingTrace = null;
|
||||
if (formResponseId) {
|
||||
pendingTrace = await this.deps.pendingRoutingTraceRepository.findByFormResponseId(formResponseId);
|
||||
} else if (queuedFormResponseId) {
|
||||
pendingTrace = await this.deps.pendingRoutingTraceRepository.findByQueuedFormResponseId(queuedFormResponseId);
|
||||
}
|
||||
|
||||
if (!pendingTrace) {
|
||||
logger.warn("Could not find pending routing trace for form response", { formResponseId, queuedFormResponseId, bookingId });
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. Extract assignment reason from trace
|
||||
const assignmentReasonData = this.extractAssignmentReasonFromTrace(
|
||||
pendingTrace.trace,
|
||||
{
|
||||
organizerEmail,
|
||||
isRerouting,
|
||||
reroutedByEmail,
|
||||
}
|
||||
);
|
||||
|
||||
let assignmentReasonId: number | undefined;
|
||||
|
||||
// 3. Create assignment reason record if we have trace-based data
|
||||
if (assignmentReasonData) {
|
||||
const createdReason = await this.deps.assignmentReasonRepository.createAssignmentReason({
|
||||
bookingId,
|
||||
reasonEnum: assignmentReasonData.reasonEnum,
|
||||
reasonString: assignmentReasonData.reasonString,
|
||||
});
|
||||
assignmentReasonId = createdReason.id;
|
||||
}
|
||||
|
||||
// 4. Create permanent routing trace
|
||||
await this.deps.routingTraceRepository.create({
|
||||
trace: pendingTrace.trace,
|
||||
formResponseId: pendingTrace.formResponseId ?? undefined,
|
||||
queuedFormResponseId: pendingTrace.queuedFormResponseId ?? undefined,
|
||||
bookingUid,
|
||||
assignmentReasonId,
|
||||
});
|
||||
|
||||
return {
|
||||
assignmentReason: assignmentReasonData
|
||||
? {
|
||||
reasonEnum: assignmentReasonData.reasonEnum,
|
||||
reasonString: assignmentReasonData.reasonString,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract assignment reason from trace steps.
|
||||
* Priority: CRM (Salesforce) > Routing Form
|
||||
* CRM assignment is only used if the booking organizer matches the CRM contact owner.
|
||||
*/
|
||||
private extractAssignmentReasonFromTrace(
|
||||
trace: RoutingTrace,
|
||||
context: {
|
||||
organizerEmail: string;
|
||||
isRerouting: boolean;
|
||||
reroutedByEmail?: string | null;
|
||||
}
|
||||
): { reasonEnum: AssignmentReasonEnum; reasonString: string } | null {
|
||||
const crmAssignmentStep = trace.find(
|
||||
(step) =>
|
||||
step.domain === ROUTING_TRACE_DOMAINS.SALESFORCE &&
|
||||
step.step === ROUTING_TRACE_STEPS.SALESFORCE_ASSIGNMENT
|
||||
);
|
||||
|
||||
if (crmAssignmentStep && crmAssignmentStep.data) {
|
||||
const {
|
||||
email,
|
||||
recordType,
|
||||
recordId,
|
||||
rrSkipToAccountLookupField,
|
||||
rrSKipToAccountLookupFieldName,
|
||||
} = crmAssignmentStep.data as {
|
||||
email?: string;
|
||||
recordType?: string | null;
|
||||
recordId?: string;
|
||||
rrSkipToAccountLookupField?: boolean;
|
||||
rrSKipToAccountLookupFieldName?: string | null;
|
||||
};
|
||||
|
||||
// Only use CRM assignment if the booking organizer matches the contact owner
|
||||
if (
|
||||
email &&
|
||||
email.toLowerCase() === context.organizerEmail.toLowerCase()
|
||||
) {
|
||||
return {
|
||||
reasonEnum: AssignmentReasonEnum.SALESFORCE_ASSIGNMENT,
|
||||
reasonString: this.buildSalesforceReasonString({
|
||||
email,
|
||||
recordType: recordType ?? "Contact", // Default to Contact if not specified
|
||||
recordId,
|
||||
rrSkipToAccountLookupField,
|
||||
rrSKipToAccountLookupFieldName,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const routingFormStep = trace.find(
|
||||
(step) =>
|
||||
step.domain === ROUTING_TRACE_DOMAINS.ROUTING_FORM &&
|
||||
step.step === ROUTING_TRACE_STEPS.ATTRIBUTE_LOGIC_EVALUATED
|
||||
);
|
||||
|
||||
if (routingFormStep && routingFormStep.data) {
|
||||
const { routeIsFallback, attributeRoutingDetails } = routingFormStep.data as {
|
||||
routeIsFallback?: boolean;
|
||||
attributeRoutingDetails?: Array<{ attributeName: string; attributeValue: string }>;
|
||||
};
|
||||
|
||||
const reasonEnum = context.isRerouting
|
||||
? AssignmentReasonEnum.REROUTED
|
||||
: routeIsFallback
|
||||
? AssignmentReasonEnum.ROUTING_FORM_ROUTING_FALLBACK
|
||||
: AssignmentReasonEnum.ROUTING_FORM_ROUTING;
|
||||
|
||||
return {
|
||||
reasonEnum,
|
||||
reasonString: this.buildRoutingFormReasonString({
|
||||
isRerouting: context.isRerouting,
|
||||
reroutedByEmail: context.reroutedByEmail,
|
||||
attributeRoutingDetails,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
logger.warn("Could not extract assignment reason from routing trace - no matching steps found", {
|
||||
traceSteps: trace.map((s) => `${s.domain}:${s.step}`),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
private buildRoutingFormReasonString(data: {
|
||||
isRerouting: boolean;
|
||||
reroutedByEmail?: string | null;
|
||||
attributeRoutingDetails?: Array<{ attributeName: string; attributeValue: string }>;
|
||||
}): string {
|
||||
const { isRerouting, reroutedByEmail, attributeRoutingDetails } = data;
|
||||
|
||||
const reroutingPart = isRerouting && reroutedByEmail ? `Rerouted by ${reroutedByEmail}` : "";
|
||||
|
||||
const attributesPart =
|
||||
attributeRoutingDetails && attributeRoutingDetails.length > 0
|
||||
? attributeRoutingDetails
|
||||
.map(({ attributeName, attributeValue }) => `${attributeName}: ${attributeValue}`)
|
||||
.join(", ")
|
||||
: "";
|
||||
|
||||
// Combine both parts, matching main branch behavior
|
||||
return `${reroutingPart} ${attributesPart}`.trim();
|
||||
}
|
||||
|
||||
private buildSalesforceReasonString(data: {
|
||||
email: string;
|
||||
recordType: string;
|
||||
recordId?: string;
|
||||
rrSkipToAccountLookupField?: boolean;
|
||||
rrSKipToAccountLookupFieldName?: string | null;
|
||||
}): string {
|
||||
const {
|
||||
email,
|
||||
recordType,
|
||||
recordId,
|
||||
rrSkipToAccountLookupField,
|
||||
rrSKipToAccountLookupFieldName,
|
||||
} = data;
|
||||
|
||||
if (rrSkipToAccountLookupField && rrSKipToAccountLookupFieldName) {
|
||||
return `Salesforce account lookup field: ${rrSKipToAccountLookupFieldName} - ${email}${
|
||||
recordId ? ` (Account ID: ${recordId})` : ""
|
||||
}`;
|
||||
}
|
||||
|
||||
const recordLabel = recordType.toLowerCase();
|
||||
return `Salesforce ${recordLabel} owner: ${email}${
|
||||
recordId ? ` (${recordType} ID: ${recordId})` : ""
|
||||
}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "public"."PendingRoutingTrace" (
|
||||
"id" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"trace" JSONB NOT NULL,
|
||||
"formResponseId" INTEGER,
|
||||
"queuedFormResponseId" TEXT,
|
||||
|
||||
CONSTRAINT "PendingRoutingTrace_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "public"."RoutingTrace" (
|
||||
"id" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"trace" JSONB NOT NULL,
|
||||
"formResponseId" INTEGER,
|
||||
"queuedFormResponseId" TEXT,
|
||||
"bookingUid" TEXT,
|
||||
"assignmentReasonId" INTEGER,
|
||||
|
||||
CONSTRAINT "RoutingTrace_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PendingRoutingTrace_formResponseId_key" ON "public"."PendingRoutingTrace"("formResponseId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PendingRoutingTrace_queuedFormResponseId_key" ON "public"."PendingRoutingTrace"("queuedFormResponseId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "RoutingTrace_formResponseId_key" ON "public"."RoutingTrace"("formResponseId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "RoutingTrace_queuedFormResponseId_key" ON "public"."RoutingTrace"("queuedFormResponseId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "RoutingTrace_bookingUid_key" ON "public"."RoutingTrace"("bookingUid");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "RoutingTrace_assignmentReasonId_key" ON "public"."RoutingTrace"("assignmentReasonId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "public"."PendingRoutingTrace" ADD CONSTRAINT "PendingRoutingTrace_formResponseId_fkey" FOREIGN KEY ("formResponseId") REFERENCES "public"."App_RoutingForms_FormResponse"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "public"."PendingRoutingTrace" ADD CONSTRAINT "PendingRoutingTrace_queuedFormResponseId_fkey" FOREIGN KEY ("queuedFormResponseId") REFERENCES "public"."App_RoutingForms_QueuedFormResponse"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "public"."RoutingTrace" ADD CONSTRAINT "RoutingTrace_formResponseId_fkey" FOREIGN KEY ("formResponseId") REFERENCES "public"."App_RoutingForms_FormResponse"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "public"."RoutingTrace" ADD CONSTRAINT "RoutingTrace_queuedFormResponseId_fkey" FOREIGN KEY ("queuedFormResponseId") REFERENCES "public"."App_RoutingForms_QueuedFormResponse"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "public"."RoutingTrace" ADD CONSTRAINT "RoutingTrace_bookingUid_fkey" FOREIGN KEY ("bookingUid") REFERENCES "public"."Booking"("uid") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "public"."RoutingTrace" ADD CONSTRAINT "RoutingTrace_assignmentReasonId_fkey" FOREIGN KEY ("assignmentReasonId") REFERENCES "public"."AssignmentReason"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddCheckConstraint: Ensure at least one of formResponseId or queuedFormResponseId is set
|
||||
ALTER TABLE "public"."PendingRoutingTrace" ADD CONSTRAINT "PendingRoutingTrace_at_least_one_response_id" CHECK ("formResponseId" IS NOT NULL OR "queuedFormResponseId" IS NOT NULL);
|
||||
|
||||
-- AddCheckConstraint: Ensure at least one of formResponseId or queuedFormResponseId is set
|
||||
ALTER TABLE "public"."RoutingTrace" ADD CONSTRAINT "RoutingTrace_at_least_one_response_id" CHECK ("formResponseId" IS NOT NULL OR "queuedFormResponseId" IS NOT NULL);
|
||||
@@ -920,6 +920,7 @@ model Booking {
|
||||
routingFormResponses RoutingFormResponseDenormalized[]
|
||||
expenseLogs CreditExpenseLog[]
|
||||
report BookingReport?
|
||||
routingTrace RoutingTrace?
|
||||
|
||||
// @@partial_index([reassignById])
|
||||
|
||||
@@ -1349,6 +1350,8 @@ model App_RoutingForms_FormResponse {
|
||||
routingFormResponseFields RoutingFormResponseField[]
|
||||
routingFormResponses RoutingFormResponseDenormalized[]
|
||||
queuedFormResponse App_RoutingForms_QueuedFormResponse?
|
||||
pendingRoutingTrace PendingRoutingTrace?
|
||||
routingTrace RoutingTrace?
|
||||
|
||||
@@unique([formFillerId, formId])
|
||||
@@index([formFillerId])
|
||||
@@ -1357,15 +1360,17 @@ model App_RoutingForms_FormResponse {
|
||||
}
|
||||
|
||||
model App_RoutingForms_QueuedFormResponse {
|
||||
id String @id @default(cuid())
|
||||
form App_RoutingForms_Form @relation(fields: [formId], references: [id], onDelete: Cascade)
|
||||
formId String
|
||||
response Json
|
||||
chosenRouteId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime? @updatedAt
|
||||
actualResponseId Int? @unique
|
||||
actualResponse App_RoutingForms_FormResponse? @relation(fields: [actualResponseId], references: [id], onDelete: Cascade)
|
||||
id String @id @default(cuid())
|
||||
form App_RoutingForms_Form @relation(fields: [formId], references: [id], onDelete: Cascade)
|
||||
formId String
|
||||
response Json
|
||||
chosenRouteId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime? @updatedAt
|
||||
actualResponseId Int? @unique
|
||||
actualResponse App_RoutingForms_FormResponse? @relation(fields: [actualResponseId], references: [id], onDelete: Cascade)
|
||||
pendingRoutingTrace PendingRoutingTrace?
|
||||
routingTrace RoutingTrace?
|
||||
}
|
||||
|
||||
model RoutingFormResponseField {
|
||||
@@ -2334,6 +2339,7 @@ model AssignmentReason {
|
||||
booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade)
|
||||
reasonEnum AssignmentReasonEnum
|
||||
reasonString String
|
||||
routingTrace RoutingTrace?
|
||||
|
||||
@@index([bookingId])
|
||||
}
|
||||
@@ -3207,3 +3213,33 @@ model AttributeSyncFieldMapping {
|
||||
@@unique([integrationAttributeSyncId, attributeId])
|
||||
@@index([integrationAttributeSyncId])
|
||||
}
|
||||
|
||||
model PendingRoutingTrace {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
trace Json
|
||||
|
||||
formResponseId Int? @unique
|
||||
formResponse App_RoutingForms_FormResponse? @relation(fields: [formResponseId], references: [id], onDelete: Cascade)
|
||||
|
||||
queuedFormResponseId String? @unique
|
||||
queuedFormResponse App_RoutingForms_QueuedFormResponse? @relation(fields: [queuedFormResponseId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model RoutingTrace {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
trace Json
|
||||
|
||||
formResponseId Int? @unique
|
||||
formResponse App_RoutingForms_FormResponse? @relation(fields: [formResponseId], references: [id], onDelete: Cascade)
|
||||
|
||||
queuedFormResponseId String? @unique
|
||||
queuedFormResponse App_RoutingForms_QueuedFormResponse? @relation(fields: [queuedFormResponseId], references: [id], onDelete: Cascade)
|
||||
|
||||
bookingUid String? @unique
|
||||
booking Booking? @relation(fields: [bookingUid], references: [uid], onDelete: Cascade)
|
||||
|
||||
assignmentReasonId Int? @unique
|
||||
assignmentReason AssignmentReason? @relation(fields: [assignmentReasonId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getSerializableForm } from "@calcom/app-store/routing-forms/lib/getSerializableForm";
|
||||
import { handleResponse } from "@calcom/features/routing-forms/lib/handleResponse";
|
||||
import { getRoutingTraceService } from "@calcom/features/routing-trace/di/RoutingTraceService.container";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
@@ -46,14 +47,32 @@ export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) =>
|
||||
form,
|
||||
});
|
||||
|
||||
return handleResponse({
|
||||
// Initialize trace service for tracking routing decisions
|
||||
const traceService = isPreview ? undefined : getRoutingTraceService();
|
||||
|
||||
const result = await handleResponse({
|
||||
response,
|
||||
identifierKeyedResponse: null,
|
||||
form: serializableForm,
|
||||
formFillerId,
|
||||
chosenRouteId,
|
||||
isPreview,
|
||||
traceService,
|
||||
});
|
||||
|
||||
// Save the pending trace
|
||||
if (traceService) {
|
||||
const formResponseId = result.formResponse?.id;
|
||||
const queuedFormResponseId = result.queuedFormResponse?.id;
|
||||
|
||||
if (formResponseId) {
|
||||
await traceService.savePendingRoutingTrace({ formResponseId });
|
||||
} else if (queuedFormResponseId) {
|
||||
await traceService.savePendingRoutingTrace({ queuedFormResponseId });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export default responseHandler;
|
||||
|
||||
Reference in New Issue
Block a user