diff --git a/packages/features/ee/workflows/lib/reminders/templates/customTemplate.test.ts b/packages/features/ee/workflows/lib/reminders/templates/customTemplate.test.ts new file mode 100644 index 0000000000..ceb0be21e0 --- /dev/null +++ b/packages/features/ee/workflows/lib/reminders/templates/customTemplate.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from "vitest"; +import customTemplate, { + formatIdentifierToVariable, + getVariableFormats, + transformBookingResponsesToVariableFormat, +} from "./customTemplate"; + +describe("formatIdentifierToVariable", () => { + it("should convert spaces to underscores and uppercase", () => { + expect(formatIdentifierToVariable("Company Name")).toBe("COMPANY_NAME"); + }); + + it("should preserve existing underscores", () => { + expect(formatIdentifierToVariable("Company_Name")).toBe("COMPANY_NAME"); + }); + + it("should handle multiple underscores", () => { + expect(formatIdentifierToVariable("Monthly_Infrastructure_Spend")).toBe("MONTHLY_INFRASTRUCTURE_SPEND"); + }); + + it("should remove special characters except underscores", () => { + expect(formatIdentifierToVariable("Company-Name!")).toBe("COMPANYNAME"); + }); + + it("should handle mixed spaces and underscores", () => { + expect(formatIdentifierToVariable("Company_Name Here")).toBe("COMPANY_NAME_HERE"); + }); + + it("should trim whitespace", () => { + expect(formatIdentifierToVariable(" Company Name ")).toBe("COMPANY_NAME"); + }); + it("should preserve numbers", () => { + expect(formatIdentifierToVariable("Company123_Name")).toBe("COMPANY123_NAME"); + }); +}); + +describe("getVariableFormats", () => { + it("should return both formats when identifier has underscores", () => { + const formats = getVariableFormats("Company_Name"); + expect(formats).toContain("COMPANY_NAME"); + expect(formats).toContain("COMPANYNAME"); + expect(formats).toHaveLength(2); + }); + + it("should return single format when no underscores in identifier", () => { + const formats = getVariableFormats("Company Name"); + expect(formats).toEqual(["COMPANY_NAME"]); + }); + + it("should return single format when identifier has no special chars", () => { + const formats = getVariableFormats("CompanyName"); + expect(formats).toEqual(["COMPANYNAME"]); + }); + + it("should handle multiple underscores", () => { + const formats = getVariableFormats("Monthly_Infrastructure_Spend"); + expect(formats).toContain("MONTHLY_INFRASTRUCTURE_SPEND"); + expect(formats).toContain("MONTHLYINFRASTRUCTURESPEND"); + expect(formats).toHaveLength(2); + }); +}); + +describe("customTemplate - variable replacement", () => { + const baseVariables = { + eventName: "Test Event", + organizerName: "Test Organizer", + attendeeName: "Test Attendee", + timeZone: "UTC", + }; + + it("should replace variables with underscores in template", () => { + const responses = transformBookingResponsesToVariableFormat({ + Company_Name: { value: "Acme Corp", label: "Company Name" }, + }); + + const result = customTemplate( + "Company: {COMPANY_NAME}", + { ...baseVariables, responses }, + "en", + undefined, + true + ); + + expect(result.text).toBe("Company: Acme Corp"); + }); + + it("should replace legacy variables without underscores in template (backward compatibility)", () => { + const responses = transformBookingResponsesToVariableFormat({ + Company_Name: { value: "Acme Corp", label: "Company Name" }, + }); + + const result = customTemplate( + "Company: {COMPANYNAME}", + { ...baseVariables, responses }, + "en", + undefined, + true + ); + + expect(result.text).toBe("Company: Acme Corp"); + }); + + it("should handle both variable formats in the same template", () => { + const responses = transformBookingResponsesToVariableFormat({ + Company_Name: { value: "Acme Corp", label: "Company Name" }, + Monthly_Spend: { value: "$5000", label: "Monthly Spend" }, + }); + + const result = customTemplate( + "Company: {COMPANY_NAME}, Spend: {MONTHLYSPEND}", + { ...baseVariables, responses }, + "en", + undefined, + true + ); + + expect(result.text).toBe("Company: Acme Corp, Spend: $5000"); + }); + + it("should handle identifiers with spaces (converted to underscores)", () => { + const responses = transformBookingResponsesToVariableFormat({ + "Company Name": { value: "Acme Corp", label: "Company Name" }, + }); + + const result = customTemplate( + "Company: {COMPANY_NAME}", + { ...baseVariables, responses }, + "en", + undefined, + true + ); + + expect(result.text).toBe("Company: Acme Corp"); + }); + + it("should handle array values", () => { + const responses = transformBookingResponsesToVariableFormat({ + Selected_Options: { value: ["Option A", "Option B", "Option C"], label: "Selected Options" }, + }); + + const result = customTemplate( + "Options: {SELECTED_OPTIONS}", + { ...baseVariables, responses }, + "en", + undefined, + true + ); + + expect(result.text).toBe("Options: Option A, Option B, Option C"); + }); +}); diff --git a/packages/features/ee/workflows/lib/reminders/templates/customTemplate.ts b/packages/features/ee/workflows/lib/reminders/templates/customTemplate.ts index 0c5bf3768a..f02a5587d9 100644 --- a/packages/features/ee/workflows/lib/reminders/templates/customTemplate.ts +++ b/packages/features/ee/workflows/lib/reminders/templates/customTemplate.ts @@ -56,6 +56,19 @@ export function transformRoutingFormResponsesToVariableFormat( } export function formatIdentifierToVariable(key: string): string { + return key + .replace(/[^a-zA-Z0-9_ ]/g, "") + .trim() + .replaceAll(" ", "_") + .toUpperCase(); +} + +/** + * Legacy version of formatIdentifierToVariable that strips underscores. + * Used for backward compatibility with templates that were created when + * underscores were being stripped from identifiers. + */ +function formatIdentifierToVariableLegacy(key: string): string { return key .replace(/[^a-zA-Z0-9 ]/g, "") .trim() @@ -63,6 +76,22 @@ export function formatIdentifierToVariable(key: string): string { .toUpperCase(); } +/** + * Returns all variable formats for a given key. + * Includes both the current format (with underscores preserved) and the legacy format + * (without underscores) for backward compatibility with existing templates. + * @returns Array of unique variable formats + */ +export function getVariableFormats(key: string): string[] { + const current = formatIdentifierToVariable(key); + const legacy = formatIdentifierToVariableLegacy(key); + + if (current === legacy) { + return [current]; + } + return [current, legacy]; +} + export type VariablesType = { eventName?: string; organizerName?: string; @@ -196,9 +225,13 @@ const customTemplate = ( // handle custom variables from form/booking responses if (variables.responses) { Object.keys(variables.responses).forEach((customInput) => { - const formatedToVariable = formatIdentifierToVariable(customInput); + const foundVariableInTemplate = variable; + const availableVariable = formatIdentifierToVariable(customInput); + // Legacy format for backward compatibility with templates created before underscore support + const availableVariableLegacyFormat = formatIdentifierToVariableLegacy(customInput); + const isFoundTemplateVariableValid = foundVariableInTemplate === availableVariable || foundVariableInTemplate === availableVariableLegacyFormat; - if (variable === formatedToVariable && variables.responses) { + if (isFoundTemplateVariableValid && variables.responses) { const response = variables.responses[customInput]; if (response?.value !== undefined) { const responseValue = response.value; @@ -206,7 +239,7 @@ const customTemplate = ( ? responseValue.join(", ") : String(responseValue); - dynamicText = dynamicText.replace(`{${variable}}`, valueString); + dynamicText = dynamicText.replace(`{${foundVariableInTemplate}}`, valueString); } } }); diff --git a/packages/features/tasker/tasks/executeAIPhoneCall.test.ts b/packages/features/tasker/tasks/executeAIPhoneCall.test.ts new file mode 100644 index 0000000000..df1f8a1370 --- /dev/null +++ b/packages/features/tasker/tasks/executeAIPhoneCall.test.ts @@ -0,0 +1,85 @@ +import { convertResponsesToVariableFormats } from "@calcom/features/tasker/tasks/executeAIPhoneCall"; +import { describe, expect, it } from "vitest"; + +describe("executeAIPhoneCall - convertResponsesToVariableFormats", () => { + it("should generate both variable formats for identifiers with underscores", () => { + const responses = { + Company_Name: { value: "Acme Corp" }, + Monthly_Infrastructure_Spend: { value: "$5000" }, + }; + + const variables = convertResponsesToVariableFormats(responses); + + // Should have both underscore and non-underscore versions + expect(variables.COMPANY_NAME).toBe("Acme Corp"); + expect(variables.COMPANYNAME).toBe("Acme Corp"); + expect(variables.MONTHLY_INFRASTRUCTURE_SPEND).toBe("$5000"); + expect(variables.MONTHLYINFRASTRUCTURESPEND).toBe("$5000"); + }); + + it("should generate single format for identifiers without underscores", () => { + const responses = { + "Company Name": { value: "Acme Corp" }, + Email: { value: "test@example.com" }, + }; + + const variables = convertResponsesToVariableFormats(responses); + + // Space-separated identifiers convert to underscore format + expect(variables.COMPANY_NAME).toBe("Acme Corp"); + // Simple identifiers stay as-is + expect(variables.EMAIL).toBe("test@example.com"); + }); + + it("should handle mixed identifiers correctly", () => { + const responses = { + Company_Name: { value: "Acme Corp" }, + "Contact Email": { value: "contact@acme.com" }, + Notes: { value: "Some notes" }, + }; + + const variables = convertResponsesToVariableFormats(responses); + + // Underscore identifier: both formats + expect(variables.COMPANY_NAME).toBe("Acme Corp"); + expect(variables.COMPANYNAME).toBe("Acme Corp"); + + // Space identifier: single format (space becomes underscore) + expect(variables.CONTACT_EMAIL).toBe("contact@acme.com"); + + // Simple identifier: single format + expect(variables.NOTES).toBe("Some notes"); + }); + + it("should preserve the same value for both variable formats", () => { + const testValue = "Test Value 123"; + const responses = { + Test_Field: { value: testValue }, + }; + + const variables = convertResponsesToVariableFormats(responses); + + expect(variables.TEST_FIELD).toBe(testValue); + expect(variables.TESTFIELD).toBe(testValue); + expect(variables.TEST_FIELD).toBe(variables.TESTFIELD); + }); + + it("should handle empty responses", () => { + const responses = {}; + + const variables = convertResponsesToVariableFormats(responses); + + expect(Object.keys(variables)).toHaveLength(0); + }); + + it("should handle undefined values", () => { + const responses = { + Field_Name: { value: undefined }, + }; + + const variables = convertResponsesToVariableFormats(responses); + + expect(variables.FIELD_NAME).toBe(""); + expect(variables.FIELDNAME).toBe(""); + }); +}); diff --git a/packages/features/tasker/tasks/executeAIPhoneCall.ts b/packages/features/tasker/tasks/executeAIPhoneCall.ts index 21cfbbb7cd..7c924dd5c6 100644 --- a/packages/features/tasker/tasks/executeAIPhoneCall.ts +++ b/packages/features/tasker/tasks/executeAIPhoneCall.ts @@ -3,7 +3,7 @@ import dayjs from "@calcom/dayjs"; import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses"; import { createDefaultAIPhoneServiceProvider } from "@calcom/features/calAIPhone"; import { handleInsufficientCredits } from "@calcom/features/ee/billing/helpers/handleInsufficientCredits"; -import { formatIdentifierToVariable } from "@calcom/features/ee/workflows/lib/reminders/templates/customTemplate"; +import { getVariableFormats } from "@calcom/features/ee/workflows/lib/reminders/templates/customTemplate"; import { WorkflowReminderRepository } from "@calcom/features/ee/workflows/lib/repository/workflowReminder"; import { FeaturesRepository } from "@calcom/features/flags/features.repository"; import { @@ -14,7 +14,6 @@ import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowE import logger from "@calcom/lib/logger"; import prisma from "@calcom/prisma"; import { CreditUsageType } from "@calcom/prisma/enums"; - interface ExecuteAIPhoneCallPayload { workflowReminderId: number; agentId: string; @@ -35,6 +34,23 @@ type BookingWithRelations = NonNullable< >["booking"] >; +/** + * Converts responses to variable format entries with both current and legacy formats + * for backward compatibility. + * Accepts both FORM_SUBMITTED_WEBHOOK_RESPONSES and CalEventResponses types. + */ +export function convertResponsesToVariableFormats( + responses: Record +) { + return Object.fromEntries( + Object.entries(responses).flatMap(([key, value]) => { + const formats = getVariableFormats(key); + const valueStr = value.value?.toString() || ""; + return formats.map((format) => [format, valueStr]); + }) + ); +} + function getVariablesFromFormResponse({ responses, eventTypeId, @@ -52,13 +68,8 @@ function getVariablesFromFormResponse({ ATTENDEE_EMAIL: submittedEmail || "", NUMBER_TO_CALL: numberToCall, eventTypeId: eventTypeId?.toString() || "", - // Include any custom form responses - ...Object.fromEntries( - Object.entries(responses || {}).map(([key, value]) => [ - formatIdentifierToVariable(key), - value.value?.toString() || "", - ]) - ), + // Include custom form responses with both current and legacy variable formats for backward compatibility + ...convertResponsesToVariableFormats(responses || {}), }; } @@ -102,13 +113,8 @@ function getVariablesFromBooking(booking: BookingWithRelations, numberToCall: st .format("h:mm A"), // DO NOT REMOVE THIS FIELD. It is used for conditional tool routing in prompts eventTypeId: booking.eventTypeId?.toString() || "", - // Include any custom form responses - ...Object.fromEntries( - Object.entries(responses || {}).map(([key, value]) => [ - formatIdentifierToVariable(key), - value.value?.toString() || "", - ]) - ), + // Include custom form responses with both current and legacy variable formats for backward compatibility + ...convertResponsesToVariableFormats(responses || {}), }; }