Skip to content

feat: Use RoutingTraceService to write assignment reasons#27225

Merged
joeauyeung merged 45 commits intomainfrom
init-routing-trace
Jan 28, 2026
Merged

feat: Use RoutingTraceService to write assignment reasons#27225
joeauyeung merged 45 commits intomainfrom
init-routing-trace

Conversation

@joeauyeung
Copy link
Copy Markdown
Contributor

@joeauyeung joeauyeung commented Jan 26, 2026

https://www.loom.com/share/241f7aa6fca04173bb200155744d7d6a

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 - 19 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
  • Fixed test failures by updating RoutingTraceService:

    • Added getStepsCount() method for debugging/testing
    • Added queuedFormResponseId support to processForBooking method (can now look up pending traces by either formResponseId or queuedFormResponseId)
    • Updated tests to properly mock assignmentReasonRepository dependency instead of direct Prisma mock
  • Security fix: Removed PII (organizer email) from log payload in extractAssignmentReasonFromTrace to comply with sensitive information logging rules (addressed Cubic AI review feedback)

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
  • Confirm processForBooking handles the case where neither formResponseId nor queuedFormResponseId is provided (returns null with warning log)
  • Verify log statements don't contain PII (organizer email was removed from warning log)

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
Link to Devin run (test fixes): https://app.devin.ai/sessions/efee0499091e4167a790df5c9a40177c
Link to Devin run (Cubic AI review fixes): https://app.devin.ai/sessions/43f1d70c08b6420d8df2220156ef0c9d
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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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

Comment thread packages/features/routing-forms/lib/findTeamMembersMatchingAttributeLogic.ts Outdated
Comment thread packages/prisma/schema.prisma
// Check for routing form attribute logic step
const routingFormStep = trace.find(
(step) =>
step.domain === "routing_form" && step.step === "attribute-logic-evaluated"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Few strings are hardcoded in the PR

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

These are hard coded right now in the assignment reason. We'll introduce a presenter class to translate these strings in the format we want

recordType: string;
recordId?: string;
rrSkipToAccountLookupField?: boolean;
rrSKipToAccountLookupFieldName?: string | null;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

typo. 'k' should be in smallcase

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I noticed this is a type in some base classes. Let's separate that into a separate PR for brevity sake

Comment thread packages/prisma/schema.prisma Outdated
devin-ai-integration Bot and others added 2 commits January 27, 2026 15:40
…n routing trace tables

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>
- 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>
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

2 issues found across 4 files (changes from recent commits).

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them.


<file name="packages/prisma/schema.prisma">

<violation number="1" location="packages/prisma/schema.prisma:3213">
P1: Rule violated: **Prevent Direct NOW() Usage in Database Queries**

`createdAt` defaults rely on bare `now()`, so the stored timestamps depend on the database server timezone rather than UTC. Please switch these Prisma defaults to a UTC-safe expression (e.g., `dbgenerated("timezone('UTC', now())")`) to comply with the timezone requirement.</violation>
</file>

<file name="packages/features/routing-forms/lib/findTeamMembersMatchingAttributeLogic.ts">

<violation number="1" location="packages/features/routing-forms/lib/findTeamMembersMatchingAttributeLogic.ts:533">
P2: `addTraceStep` logs fallback executions using the main logic’s query, so fallback routing traces/assignment reasons show the wrong attribute details.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread packages/prisma/schema.prisma
const addTraceStep = (checkedFallback: boolean) => {
if (routingTraceService) {
const attributeRoutingDetails = extractAttributeRoutingDetails({
resolvedAttributesQueryValue,
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Jan 27, 2026

Choose a reason for hiding this comment

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

P2: addTraceStep logs fallback executions using the main logic’s query, so fallback routing traces/assignment reasons show the wrong attribute details.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/features/routing-forms/lib/findTeamMembersMatchingAttributeLogic.ts, line 533:

<comment>`addTraceStep` logs fallback executions using the main logic’s query, so fallback routing traces/assignment reasons show the wrong attribute details.</comment>

<file context>
@@ -522,28 +526,32 @@ export async function findTeamMembersMatchingAttributeLogic(
+  const addTraceStep = (checkedFallback: boolean) => {
+    if (routingTraceService) {
+      const attributeRoutingDetails = extractAttributeRoutingDetails({
+        resolvedAttributesQueryValue,
+        attributesOfTheOrg,
+        dynamicFieldValueOperands,
</file context>
Fix with Cubic

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Follow up PR will address this

@github-actions
Copy link
Copy Markdown
Contributor

Devin AI is addressing Cubic AI's review feedback

A Devin session has been created to address the issues identified by Cubic AI.

View Devin Session

devin-ai-integration Bot and others added 5 commits January 27, 2026 17:47
…ng 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 <>
Use 'UTC'::text cast in timezone() function to match Prisma's generated SQL.

Co-Authored-By: unknown <>
Remove ::text cast from timezone() function to match what Prisma generates
from the schema definition.

Co-Authored-By: unknown <>
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 <>
…attern)

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

1 issue found across 1 file (changes from recent commits).

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them.


<file name="packages/prisma/migrations/20260127035022_add_routing_trace_tables/migration.sql">

<violation number="1" location="packages/prisma/migrations/20260127035022_add_routing_trace_tables/migration.sql:4">
P2: CURRENT_TIMESTAMP uses the session time zone and will be cast to TIMESTAMP without timezone, so this change stops normalizing createdAt to UTC. That can lead to inconsistent timestamps if the session TZ is not UTC. Use the explicit UTC expression to keep stored values normalized.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

-- CreateTable
CREATE TABLE "public"."PendingRoutingTrace" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Jan 28, 2026

Choose a reason for hiding this comment

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

P2: CURRENT_TIMESTAMP uses the session time zone and will be cast to TIMESTAMP without timezone, so this change stops normalizing createdAt to UTC. That can lead to inconsistent timestamps if the session TZ is not UTC. Use the explicit UTC expression to keep stored values normalized.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/prisma/migrations/20260127035022_add_routing_trace_tables/migration.sql, line 4:

<comment>CURRENT_TIMESTAMP uses the session time zone and will be cast to TIMESTAMP without timezone, so this change stops normalizing createdAt to UTC. That can lead to inconsistent timestamps if the session TZ is not UTC. Use the explicit UTC expression to keep stored values normalized.</comment>

<file context>
@@ -1,7 +1,7 @@
 CREATE TABLE "public"."PendingRoutingTrace" (
     "id" TEXT NOT NULL,
-    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT timezone('UTC'::text, now()),
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
     "trace" JSONB NOT NULL,
     "formResponseId" INTEGER,
</file context>

Fix confidence (alpha): 8.5/10

Suggested change
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT timezone('UTC'::text, now()),
Fix with Cubic

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The code base migration files use CURRENT_TIMESTAMP

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for the feedback! I've saved this as a new learning to improve future reviews.

@github-actions
Copy link
Copy Markdown
Contributor

Devin AI is addressing Cubic AI's review feedback

A Devin session has been created to address the issues identified by Cubic AI.

View Devin Session

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

1 issue found across 1 file (changes from recent commits).

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them.


<file name="packages/prisma/schema.prisma">

<violation number="1" location="packages/prisma/schema.prisma:3218">
P1: Rule violated: **Prevent Direct NOW() Usage in Database Queries**

Avoid direct `now()` defaults; the rule requires timezone-specified timestamps. Revert to the UTC-normalized default to prevent inconsistent timestamps across environments.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.


model PendingRoutingTrace {
id String @id @default(uuid())
createdAt DateTime @default(now())
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Jan 28, 2026

Choose a reason for hiding this comment

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

P1: Rule violated: Prevent Direct NOW() Usage in Database Queries

Avoid direct now() defaults; the rule requires timezone-specified timestamps. Revert to the UTC-normalized default to prevent inconsistent timestamps across environments.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/prisma/schema.prisma, line 3218:

<comment>Avoid direct `now()` defaults; the rule requires timezone-specified timestamps. Revert to the UTC-normalized default to prevent inconsistent timestamps across environments.</comment>

<file context>
@@ -3215,7 +3215,7 @@ model AttributeSyncFieldMapping {
 model PendingRoutingTrace {
   id        String   @id @default(uuid())
-  createdAt DateTime @default(dbgenerated("timezone('UTC'::text, now())"))
+  createdAt DateTime @default(now())
   trace     Json
 
</file context>

Fix confidence (alpha): 8.5/10

Suggested change
createdAt DateTime @default(now())
createdAt DateTime @default(dbgenerated("timezone('UTC'::text, now())"))
Fix with Cubic

@github-actions
Copy link
Copy Markdown
Contributor

Devin AI is addressing Cubic AI's review feedback

A Devin session has been created to address the issues identified by Cubic AI.

View Devin Session

@devin-ai-integration
Copy link
Copy Markdown
Contributor

I reviewed the Cubic AI feedback on this PR. The issue flagged at packages/prisma/schema.prisma:3218 regarding the @default(now()) usage has a confidence score of 8.5/10, which is below the 9/10 threshold required for automated fixes.

Per the review guidelines, I'm skipping this issue since the confidence score doesn't meet the threshold. If you'd like me to address this anyway, please let me know and I'll make the change to use @default(dbgenerated("timezone('UTC'::text, now())")) instead.

Copy link
Copy Markdown
Contributor

@pedroccastro pedroccastro left a comment

Choose a reason for hiding this comment

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

Hey @joeauyeung! Thanks for putting this together, the Loom video was helpful. The implementation looks solid 👍

One question on data lifecycle: I see PendingRoutingTrace records are created on form submission and processForBooking creates the permanent RoutingTrace, but I didn't find where PendingRoutingTrace gets deleted after processing. Is cleanup handled elsewhere or planned for a follow-up?

Happy to discuss or help with anything!

@joeauyeung
Copy link
Copy Markdown
Contributor Author

Thanks for the review @pedroccastro. In a follow up, we'll reuse the same clean up method that we use for pending routing form submissions.

Copy link
Copy Markdown
Contributor

@pedroccastro pedroccastro left a comment

Choose a reason for hiding this comment

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

Looks good, looking forward to the follow-up PRs! 👍

@joeauyeung joeauyeung merged commit 620445d into main Jan 28, 2026
50 checks passed
@joeauyeung joeauyeung deleted the init-routing-trace branch January 28, 2026 17:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core area: core, team members only enterprise area: enterprise, audit log, organisation, SAML, SSO ❗️ migrations contains migration files ready-for-e2e size/XXL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants