Skip to content

Conversation

@joeauyeung
Copy link
Contributor

@joeauyeung joeauyeung commented Jan 26, 2026

What does this PR do?

This PR introduces a RoutingTraceService to track routing decisions and write assignment reasons for bookings, replacing the previous approach that relied on URL params or post-processing after booking creation.

Key Changes

New Database Models:

  • PendingRoutingTraces - Stores routing trace data temporarily between form submission and booking creation
  • RoutingTrace - Permanent storage linked to bookings, form responses, and assignment reasons

New Service Architecture:

  • RoutingTraceService - Core service that collects routing steps and processes them into assignment reasons
  • Repository pattern with IPendingRoutingTraceRepository and IRoutingTraceRepository interfaces
  • DI container setup in RoutingTraceService.container.ts

Integration Points:

  • handleResponse now accepts a traceService parameter to track routing form decisions
  • findTeamMembersMatchingAttributeLogic records attribute logic evaluation steps
  • routerGetCrmContactOwnerEmail records CRM assignment steps
  • RegularBookingService uses RoutingTraceService.processForBooking() instead of AssignmentReasonRecorder

Trace Steps Recorded:

  • routing_form domain: attribute-logic-evaluated step with route name, fallback status, and attribute details
  • CRM domain (e.g., salesforce): {appSlug}_assignment step with contact owner details

Updates since last revision

  • Added comprehensive unit tests for the new routing trace functionality:
    • RoutingTraceService.test.ts - 20 tests covering step tracking, pending trace saving, and assignment reason extraction
    • PrismaPendingRoutingTraceRepository.test.ts - 7 tests for pending trace CRUD operations
    • PrismaRoutingTraceRepository.test.ts - 5 tests for permanent trace creation

Mandatory Tasks (DO NOT REMOVE)

  • I have self-reviewed the code (A decent size PR without self-review might be rejected).
  • I have updated the developer docs in /docs if this PR makes changes that would require a documentation change. N/A - internal service change.
  • I confirm automated tests are in place that prove my fix is effective or that my feature works.

How should this be tested?

  1. Create a routing form with attribute-based routing logic
  2. Submit the form and complete a booking
  3. Verify that:
    • A PendingRoutingTraces record is created after form submission
    • A RoutingTrace record is created after booking completion
    • The AssignmentReason is correctly populated based on the routing trace

For CRM routing:

  1. Configure Salesforce CRM integration with round-robin lead skip
  2. Submit a routing form where the contact owner is a team member
  3. Verify the assignment reason reflects the CRM ownership

Human Review Checklist

  • Verify the extractAssignmentReasonFromTrace logic correctly prioritizes CRM over routing form assignment
  • Confirm the CRM assignment check (organizer email must match contact owner) is the intended behavior
  • Review error handling in RegularBookingService - failures are logged as warnings but don't block booking
  • Ensure database migration is correct for new PendingRoutingTraces and RoutingTrace tables
  • Verify no edge cases are missed from the removed AssignmentReasonRecorder logic

Checklist

  • I have read the contributing guide
  • My code follows the style guidelines of this project
  • I have checked if my changes generate no new warnings
  • My PR is not too large (>500 lines or >10 files) - This PR is larger due to new service architecture

Link to Devin run: https://app.devin.ai/sessions/1f674b7b8e4747249113cc2da6142b24
Requested by: joe@cal.com (@joeauyeung)

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>
@@ -10,10 +11,12 @@ export default async function routerGetCrmContactOwnerEmail({
attributeRoutingConfig,
identifierKeyedResponse,
action,
routingTraceService,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to pass the routingTraceService down from getRoutedURL down to the actual functions that handle the routing. getRoutedMembers is wrapped using Sentry's withReporting which creates it's own async context so we cannot rely on AsyncLocalStorage for the RoutingTraceService

if (!eventType.hosts.some((host) => host.user.email === contactOwner.email))
return null;

if (routingTraceService && contactOwner.email && contactOwner.crmAppSlug) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Change to the file is here, we're recording the routing trace step. With these values stored, we don't have to rely on these values being passed through URL params to generate the assignment reason.

Comment on lines 1913 to 1920
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we're using the PendingRoutingTrace we don't need to rely on URL params being passed.

Comment on lines +465 to +472
routingTraceService.addStep({
domain: "routing_form",
step: "attribute-logic-evaluated",
data: {
routeName,
routeIsFallback,
attributeRoutingDetails,
},
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With this data stored, we don't need to do the calculation to get the name value pairs of the attributes used to route in RegularBookingService when recording the assignment reason.

Comment on lines +56 to +60
/**
* Process pending routing trace for a booking.
* Looks up the pending trace, extracts assignment reason, creates permanent trace.
*/
async processForBooking(args: {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Phase 1 of the routing trace project is to replace how we're recording the assignment reason with the stored pending routing trace.

This method will change as we continue to work on the routing trace.

reasonString: this.buildSalesforceReasonString({
email,
recordType: recordType ?? "Contact", // Default to Contact if not specified
recordId,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One advantage of taking this approach is we can pass the Salesforce record id around without exposing it in URL params

@github-actions
Copy link
Contributor

github-actions bot commented Jan 26, 2026

E2E results are ready!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants