Skip to content

Commit 50c5423

Browse files
fix: support underscores in workflow template variables with backward compatibility (#27571)
## What does this PR do? Fixes an issue where underscores in form field identifiers were being stripped when converting to workflow template variables. According to the documentation, users should be able to use variables like `{COMPANY_NAME}` or `{MONTHLY_INFRASTRUCTURE_SPEND}`, but the regex in `formatIdentifierToVariable` was removing underscores, causing these variables to not match. **The problem:** - Form field identifier: `Company_Name` or `Monthly_Infrastructure_Spend` - Expected variable: `{COMPANY_NAME}` or `{MONTHLY_INFRASTRUCTURE_SPEND}` - Actual variable (before fix): `{COMPANYNAME}` or `{MONTHLYINFRASTRUCTURESPEND}` **The fix:** - Updated the regex from `[^a-zA-Z0-9 ]` to `[^a-zA-Z0-9_ ]` to preserve underscores in `formatIdentifierToVariable` - Added a private `formatIdentifierToVariableLegacy` function that maintains the old behavior (strips underscores) - Added `getVariableFormats` helper that returns both current and legacy formats for backward compatibility - Added `convertResponsesToVariableFormats` helper in `executeAIPhoneCall.ts` to convert form responses to both variable formats - Updated `customTemplate.ts` matching logic to support both variable formats - Updated `executeAIPhoneCall.ts` to include both variable formats when building dynamic variables This ensures existing templates using `{COMPANYNAME}` continue to work while new templates can use the documented `{COMPANY_NAME}` format. ## Mandatory Tasks (DO NOT REMOVE) - [x] I have self-reviewed the code (A decent size PR without self-review might be rejected). - [x] I have updated the developer docs in /docs if this PR makes changes that would require a documentation change. N/A - no docs changes needed. - [x] 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 a field identifier containing underscores (e.g., `Company_Name`) 2. Create a workflow with a template using `{COMPANY_NAME}` (with underscore) 3. Submit the form and verify the variable is replaced correctly 4. Also test with `{COMPANYNAME}` (without underscore) to verify backward compatibility **Automated tests added:** - `customTemplate.test.ts` - 15 tests covering `formatIdentifierToVariable`, `getVariableFormats`, and template variable replacement - `executeAIPhoneCall.test.ts` - 6 tests covering `convertResponsesToVariableFormats` function for form responses ## Human Review Checklist - [ ] Verify the regex change `[^a-zA-Z0-9_ ]` correctly preserves underscores - [ ] Verify backward compatibility: both `{COMPANY_NAME}` and `{COMPANYNAME}` should work for the same form field - [ ] Verify `convertResponsesToVariableFormats` correctly generates both variable formats - [ ] Review test coverage for edge cases (empty responses, undefined values) --- Link to Devin run: https://app.devin.ai/sessions/9d5d90178714493086d94691865c3e07 Requested by: @hariombalhara <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/calcom/cal.com/pull/27571"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end -->
1 parent 7716f8c commit 50c5423

4 files changed

Lines changed: 294 additions & 19 deletions

File tree

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { describe, expect, it } from "vitest";
2+
import customTemplate, {
3+
formatIdentifierToVariable,
4+
getVariableFormats,
5+
transformBookingResponsesToVariableFormat,
6+
} from "./customTemplate";
7+
8+
describe("formatIdentifierToVariable", () => {
9+
it("should convert spaces to underscores and uppercase", () => {
10+
expect(formatIdentifierToVariable("Company Name")).toBe("COMPANY_NAME");
11+
});
12+
13+
it("should preserve existing underscores", () => {
14+
expect(formatIdentifierToVariable("Company_Name")).toBe("COMPANY_NAME");
15+
});
16+
17+
it("should handle multiple underscores", () => {
18+
expect(formatIdentifierToVariable("Monthly_Infrastructure_Spend")).toBe("MONTHLY_INFRASTRUCTURE_SPEND");
19+
});
20+
21+
it("should remove special characters except underscores", () => {
22+
expect(formatIdentifierToVariable("Company-Name!")).toBe("COMPANYNAME");
23+
});
24+
25+
it("should handle mixed spaces and underscores", () => {
26+
expect(formatIdentifierToVariable("Company_Name Here")).toBe("COMPANY_NAME_HERE");
27+
});
28+
29+
it("should trim whitespace", () => {
30+
expect(formatIdentifierToVariable(" Company Name ")).toBe("COMPANY_NAME");
31+
});
32+
it("should preserve numbers", () => {
33+
expect(formatIdentifierToVariable("Company123_Name")).toBe("COMPANY123_NAME");
34+
});
35+
});
36+
37+
describe("getVariableFormats", () => {
38+
it("should return both formats when identifier has underscores", () => {
39+
const formats = getVariableFormats("Company_Name");
40+
expect(formats).toContain("COMPANY_NAME");
41+
expect(formats).toContain("COMPANYNAME");
42+
expect(formats).toHaveLength(2);
43+
});
44+
45+
it("should return single format when no underscores in identifier", () => {
46+
const formats = getVariableFormats("Company Name");
47+
expect(formats).toEqual(["COMPANY_NAME"]);
48+
});
49+
50+
it("should return single format when identifier has no special chars", () => {
51+
const formats = getVariableFormats("CompanyName");
52+
expect(formats).toEqual(["COMPANYNAME"]);
53+
});
54+
55+
it("should handle multiple underscores", () => {
56+
const formats = getVariableFormats("Monthly_Infrastructure_Spend");
57+
expect(formats).toContain("MONTHLY_INFRASTRUCTURE_SPEND");
58+
expect(formats).toContain("MONTHLYINFRASTRUCTURESPEND");
59+
expect(formats).toHaveLength(2);
60+
});
61+
});
62+
63+
describe("customTemplate - variable replacement", () => {
64+
const baseVariables = {
65+
eventName: "Test Event",
66+
organizerName: "Test Organizer",
67+
attendeeName: "Test Attendee",
68+
timeZone: "UTC",
69+
};
70+
71+
it("should replace variables with underscores in template", () => {
72+
const responses = transformBookingResponsesToVariableFormat({
73+
Company_Name: { value: "Acme Corp", label: "Company Name" },
74+
});
75+
76+
const result = customTemplate(
77+
"Company: {COMPANY_NAME}",
78+
{ ...baseVariables, responses },
79+
"en",
80+
undefined,
81+
true
82+
);
83+
84+
expect(result.text).toBe("Company: Acme Corp");
85+
});
86+
87+
it("should replace legacy variables without underscores in template (backward compatibility)", () => {
88+
const responses = transformBookingResponsesToVariableFormat({
89+
Company_Name: { value: "Acme Corp", label: "Company Name" },
90+
});
91+
92+
const result = customTemplate(
93+
"Company: {COMPANYNAME}",
94+
{ ...baseVariables, responses },
95+
"en",
96+
undefined,
97+
true
98+
);
99+
100+
expect(result.text).toBe("Company: Acme Corp");
101+
});
102+
103+
it("should handle both variable formats in the same template", () => {
104+
const responses = transformBookingResponsesToVariableFormat({
105+
Company_Name: { value: "Acme Corp", label: "Company Name" },
106+
Monthly_Spend: { value: "$5000", label: "Monthly Spend" },
107+
});
108+
109+
const result = customTemplate(
110+
"Company: {COMPANY_NAME}, Spend: {MONTHLYSPEND}",
111+
{ ...baseVariables, responses },
112+
"en",
113+
undefined,
114+
true
115+
);
116+
117+
expect(result.text).toBe("Company: Acme Corp, Spend: $5000");
118+
});
119+
120+
it("should handle identifiers with spaces (converted to underscores)", () => {
121+
const responses = transformBookingResponsesToVariableFormat({
122+
"Company Name": { value: "Acme Corp", label: "Company Name" },
123+
});
124+
125+
const result = customTemplate(
126+
"Company: {COMPANY_NAME}",
127+
{ ...baseVariables, responses },
128+
"en",
129+
undefined,
130+
true
131+
);
132+
133+
expect(result.text).toBe("Company: Acme Corp");
134+
});
135+
136+
it("should handle array values", () => {
137+
const responses = transformBookingResponsesToVariableFormat({
138+
Selected_Options: { value: ["Option A", "Option B", "Option C"], label: "Selected Options" },
139+
});
140+
141+
const result = customTemplate(
142+
"Options: {SELECTED_OPTIONS}",
143+
{ ...baseVariables, responses },
144+
"en",
145+
undefined,
146+
true
147+
);
148+
149+
expect(result.text).toBe("Options: Option A, Option B, Option C");
150+
});
151+
});

packages/features/ee/workflows/lib/reminders/templates/customTemplate.ts

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,42 @@ export function transformRoutingFormResponsesToVariableFormat(
5656
}
5757

5858
export function formatIdentifierToVariable(key: string): string {
59+
return key
60+
.replace(/[^a-zA-Z0-9_ ]/g, "")
61+
.trim()
62+
.replaceAll(" ", "_")
63+
.toUpperCase();
64+
}
65+
66+
/**
67+
* Legacy version of formatIdentifierToVariable that strips underscores.
68+
* Used for backward compatibility with templates that were created when
69+
* underscores were being stripped from identifiers.
70+
*/
71+
function formatIdentifierToVariableLegacy(key: string): string {
5972
return key
6073
.replace(/[^a-zA-Z0-9 ]/g, "")
6174
.trim()
6275
.replaceAll(" ", "_")
6376
.toUpperCase();
6477
}
6578

79+
/**
80+
* Returns all variable formats for a given key.
81+
* Includes both the current format (with underscores preserved) and the legacy format
82+
* (without underscores) for backward compatibility with existing templates.
83+
* @returns Array of unique variable formats
84+
*/
85+
export function getVariableFormats(key: string): string[] {
86+
const current = formatIdentifierToVariable(key);
87+
const legacy = formatIdentifierToVariableLegacy(key);
88+
89+
if (current === legacy) {
90+
return [current];
91+
}
92+
return [current, legacy];
93+
}
94+
6695
export type VariablesType = {
6796
eventName?: string;
6897
organizerName?: string;
@@ -196,17 +225,21 @@ const customTemplate = (
196225
// handle custom variables from form/booking responses
197226
if (variables.responses) {
198227
Object.keys(variables.responses).forEach((customInput) => {
199-
const formatedToVariable = formatIdentifierToVariable(customInput);
228+
const foundVariableInTemplate = variable;
229+
const availableVariable = formatIdentifierToVariable(customInput);
230+
// Legacy format for backward compatibility with templates created before underscore support
231+
const availableVariableLegacyFormat = formatIdentifierToVariableLegacy(customInput);
232+
const isFoundTemplateVariableValid = foundVariableInTemplate === availableVariable || foundVariableInTemplate === availableVariableLegacyFormat;
200233

201-
if (variable === formatedToVariable && variables.responses) {
234+
if (isFoundTemplateVariableValid && variables.responses) {
202235
const response = variables.responses[customInput];
203236
if (response?.value !== undefined) {
204237
const responseValue = response.value;
205238
const valueString = Array.isArray(responseValue)
206239
? responseValue.join(", ")
207240
: String(responseValue);
208241

209-
dynamicText = dynamicText.replace(`{${variable}}`, valueString);
242+
dynamicText = dynamicText.replace(`{${foundVariableInTemplate}}`, valueString);
210243
}
211244
}
212245
});
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { convertResponsesToVariableFormats } from "@calcom/features/tasker/tasks/executeAIPhoneCall";
2+
import { describe, expect, it } from "vitest";
3+
4+
describe("executeAIPhoneCall - convertResponsesToVariableFormats", () => {
5+
it("should generate both variable formats for identifiers with underscores", () => {
6+
const responses = {
7+
Company_Name: { value: "Acme Corp" },
8+
Monthly_Infrastructure_Spend: { value: "$5000" },
9+
};
10+
11+
const variables = convertResponsesToVariableFormats(responses);
12+
13+
// Should have both underscore and non-underscore versions
14+
expect(variables.COMPANY_NAME).toBe("Acme Corp");
15+
expect(variables.COMPANYNAME).toBe("Acme Corp");
16+
expect(variables.MONTHLY_INFRASTRUCTURE_SPEND).toBe("$5000");
17+
expect(variables.MONTHLYINFRASTRUCTURESPEND).toBe("$5000");
18+
});
19+
20+
it("should generate single format for identifiers without underscores", () => {
21+
const responses = {
22+
"Company Name": { value: "Acme Corp" },
23+
Email: { value: "test@example.com" },
24+
};
25+
26+
const variables = convertResponsesToVariableFormats(responses);
27+
28+
// Space-separated identifiers convert to underscore format
29+
expect(variables.COMPANY_NAME).toBe("Acme Corp");
30+
// Simple identifiers stay as-is
31+
expect(variables.EMAIL).toBe("test@example.com");
32+
});
33+
34+
it("should handle mixed identifiers correctly", () => {
35+
const responses = {
36+
Company_Name: { value: "Acme Corp" },
37+
"Contact Email": { value: "contact@acme.com" },
38+
Notes: { value: "Some notes" },
39+
};
40+
41+
const variables = convertResponsesToVariableFormats(responses);
42+
43+
// Underscore identifier: both formats
44+
expect(variables.COMPANY_NAME).toBe("Acme Corp");
45+
expect(variables.COMPANYNAME).toBe("Acme Corp");
46+
47+
// Space identifier: single format (space becomes underscore)
48+
expect(variables.CONTACT_EMAIL).toBe("contact@acme.com");
49+
50+
// Simple identifier: single format
51+
expect(variables.NOTES).toBe("Some notes");
52+
});
53+
54+
it("should preserve the same value for both variable formats", () => {
55+
const testValue = "Test Value 123";
56+
const responses = {
57+
Test_Field: { value: testValue },
58+
};
59+
60+
const variables = convertResponsesToVariableFormats(responses);
61+
62+
expect(variables.TEST_FIELD).toBe(testValue);
63+
expect(variables.TESTFIELD).toBe(testValue);
64+
expect(variables.TEST_FIELD).toBe(variables.TESTFIELD);
65+
});
66+
67+
it("should handle empty responses", () => {
68+
const responses = {};
69+
70+
const variables = convertResponsesToVariableFormats(responses);
71+
72+
expect(Object.keys(variables)).toHaveLength(0);
73+
});
74+
75+
it("should handle undefined values", () => {
76+
const responses = {
77+
Field_Name: { value: undefined },
78+
};
79+
80+
const variables = convertResponsesToVariableFormats(responses);
81+
82+
expect(variables.FIELD_NAME).toBe("");
83+
expect(variables.FIELDNAME).toBe("");
84+
});
85+
});

packages/features/tasker/tasks/executeAIPhoneCall.ts

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import dayjs from "@calcom/dayjs";
33
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
44
import { createDefaultAIPhoneServiceProvider } from "@calcom/features/calAIPhone";
55
import { handleInsufficientCredits } from "@calcom/features/ee/billing/helpers/handleInsufficientCredits";
6-
import { formatIdentifierToVariable } from "@calcom/features/ee/workflows/lib/reminders/templates/customTemplate";
6+
import { getVariableFormats } from "@calcom/features/ee/workflows/lib/reminders/templates/customTemplate";
77
import { WorkflowReminderRepository } from "@calcom/features/ee/workflows/lib/repository/workflowReminder";
88
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
99
import {
@@ -14,7 +14,6 @@ import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowE
1414
import logger from "@calcom/lib/logger";
1515
import prisma from "@calcom/prisma";
1616
import { CreditUsageType } from "@calcom/prisma/enums";
17-
1817
interface ExecuteAIPhoneCallPayload {
1918
workflowReminderId: number;
2019
agentId: string;
@@ -35,6 +34,23 @@ type BookingWithRelations = NonNullable<
3534
>["booking"]
3635
>;
3736

37+
/**
38+
* Converts responses to variable format entries with both current and legacy formats
39+
* for backward compatibility.
40+
* Accepts both FORM_SUBMITTED_WEBHOOK_RESPONSES and CalEventResponses types.
41+
*/
42+
export function convertResponsesToVariableFormats(
43+
responses: Record<string, { value?: unknown }>
44+
) {
45+
return Object.fromEntries(
46+
Object.entries(responses).flatMap(([key, value]) => {
47+
const formats = getVariableFormats(key);
48+
const valueStr = value.value?.toString() || "";
49+
return formats.map((format) => [format, valueStr]);
50+
})
51+
);
52+
}
53+
3854
function getVariablesFromFormResponse({
3955
responses,
4056
eventTypeId,
@@ -52,13 +68,8 @@ function getVariablesFromFormResponse({
5268
ATTENDEE_EMAIL: submittedEmail || "",
5369
NUMBER_TO_CALL: numberToCall,
5470
eventTypeId: eventTypeId?.toString() || "",
55-
// Include any custom form responses
56-
...Object.fromEntries(
57-
Object.entries(responses || {}).map(([key, value]) => [
58-
formatIdentifierToVariable(key),
59-
value.value?.toString() || "",
60-
])
61-
),
71+
// Include custom form responses with both current and legacy variable formats for backward compatibility
72+
...convertResponsesToVariableFormats(responses || {}),
6273
};
6374
}
6475

@@ -102,13 +113,8 @@ function getVariablesFromBooking(booking: BookingWithRelations, numberToCall: st
102113
.format("h:mm A"),
103114
// DO NOT REMOVE THIS FIELD. It is used for conditional tool routing in prompts
104115
eventTypeId: booking.eventTypeId?.toString() || "",
105-
// Include any custom form responses
106-
...Object.fromEntries(
107-
Object.entries(responses || {}).map(([key, value]) => [
108-
formatIdentifierToVariable(key),
109-
value.value?.toString() || "",
110-
])
111-
),
116+
// Include custom form responses with both current and legacy variable formats for backward compatibility
117+
...convertResponsesToVariableFormats(responses || {}),
112118
};
113119
}
114120

0 commit comments

Comments
 (0)