From e77f737036de5aeff7366db894dda555ac94d242 Mon Sep 17 00:00:00 2001 From: Matthew Chambers Date: Thu, 9 Apr 2026 15:21:43 -0400 Subject: [PATCH] fix: Handle dots in custom field names for segments, workflows, and templates Custom fields with dots in their names (e.g., "prefix.key") were broken because the code split on "." to build path lookups, interpreting them as nested paths instead of literal flat JSON keys. - SegmentService: Use [jsonPath] instead of jsonPath.split('.') since contact data is always a flat JSON object - WorkflowExecutionService: Split only on the first dot to separate the top-level field from the custom field name - template.ts: Use recursive first-dot splitting with direct key lookup before descending into nested objects Fixes #346 Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/api/src/services/SegmentService.ts | 5 ++++- .../src/services/WorkflowExecutionService.ts | 10 ++++++++- packages/shared/src/template.ts | 21 ++++++++++++++----- 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/apps/api/src/services/SegmentService.ts b/apps/api/src/services/SegmentService.ts index 6c1a7224..9d8040f3 100644 --- a/apps/api/src/services/SegmentService.ts +++ b/apps/api/src/services/SegmentService.ts @@ -818,7 +818,10 @@ export class SegmentService { value: unknown, unit?: 'days' | 'hours' | 'minutes', ): Prisma.ContactWhereInput { - const path = jsonPath.split('.'); + // Use the entire jsonPath as a single path element. + // Contact data is a flat JSON object, so field names like "prefix.key" + // must be treated as literal keys, not nested paths. + const path = [jsonPath]; switch (operator) { case 'equals': diff --git a/apps/api/src/services/WorkflowExecutionService.ts b/apps/api/src/services/WorkflowExecutionService.ts index a418c815..8426c85d 100644 --- a/apps/api/src/services/WorkflowExecutionService.ts +++ b/apps/api/src/services/WorkflowExecutionService.ts @@ -1111,7 +1111,15 @@ export class WorkflowExecutionService { normalizedField = field.substring(8); // Remove "contact." prefix, leaving "data.X" } - const parts = normalizedField.split('.'); + // Split only on the first dot to separate the top-level field (e.g., "data") + // from the custom field name. This preserves dots in custom field names + // (e.g., "data.prefix.key" → ["data", "prefix.key"]). + const firstDotIndex = normalizedField.indexOf('.'); + const parts = + firstDotIndex === -1 + ? [normalizedField] + : [normalizedField.substring(0, firstDotIndex), normalizedField.substring(firstDotIndex + 1)]; + let value: unknown = data; for (const part of parts) { diff --git a/packages/shared/src/template.ts b/packages/shared/src/template.ts index b1198b24..9abdce7b 100644 --- a/packages/shared/src/template.ts +++ b/packages/shared/src/template.ts @@ -13,13 +13,24 @@ export function renderTemplate(template: string, variables: Record s.trim()); // Handle nested property access (e.g., data.firstName) + // Uses recursive first-dot splitting so that literal dots in custom field + // names (e.g., "prefix.key") are resolved correctly: direct key lookup is + // tried before descending into nested objects. const getValue = (obj: Record, path: string): unknown => { - return path.split('.').reduce((current: Record | unknown, key) => { - if (current && typeof current === 'object' && !Array.isArray(current)) { - return (current as Record)[key]; - } + if (path in obj) { + return obj[path]; + } + const firstDotIndex = path.indexOf('.'); + if (firstDotIndex === -1) { return undefined; - }, obj); + } + const firstKey = path.substring(0, firstDotIndex); + const rest = path.substring(firstDotIndex + 1); + const next = obj[firstKey]; + if (next && typeof next === 'object' && !Array.isArray(next)) { + return getValue(next as Record, rest); + } + return undefined; }; // Try multiple lookup strategies