Files
calendar/packages/features/ee/workflows/lib/reminders/templates/customTemplate.ts
T
Hariom BalharaandGitHub 50c5423ba7 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 -->
2026-02-12 17:21:42 +05:30

256 lines
8.9 KiB
TypeScript

import { guessEventLocationType } from "@calcom/app-store/locations";
import type { FORM_SUBMITTED_WEBHOOK_RESPONSES } from "@calcom/app-store/routing-forms/lib/formSubmissionUtils";
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import { APP_NAME } from "@calcom/lib/constants";
import { TimeFormat } from "@calcom/lib/timeFormat";
import type { CalEventResponses } from "@calcom/types/Calendar";
export type WorkflowVariableResponses = Record<
string,
{
value:
| string
| number
| boolean
| string[]
| Record<string, string>
| { value: string; optionValue: string };
}
>;
export function transformBookingResponsesToVariableFormat(
responses: CalEventResponses | null | undefined
): WorkflowVariableResponses | null {
if (!responses) return null;
const transformed: WorkflowVariableResponses = {};
for (const [key, response] of Object.entries(responses)) {
if (response?.value !== undefined) {
transformed[key] = {
value: response.value,
};
}
}
return Object.keys(transformed).length > 0 ? transformed : null;
}
export function transformRoutingFormResponsesToVariableFormat(
responses: FORM_SUBMITTED_WEBHOOK_RESPONSES | null | undefined
): WorkflowVariableResponses | null {
if (!responses) return null;
const transformed: WorkflowVariableResponses = {};
for (const [key, response] of Object.entries(responses)) {
if (response?.value !== undefined) {
transformed[key] = {
value: response.value,
};
}
}
return Object.keys(transformed).length > 0 ? transformed : null;
}
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()
.replaceAll(" ", "_")
.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;
attendeeName?: string;
attendeeFirstName?: string;
attendeeLastName?: string;
attendeeEmail?: string;
eventDate?: Dayjs;
eventEndTime?: Dayjs;
timeZone?: string;
location?: string | null;
additionalNotes?: string | null;
responses?: WorkflowVariableResponses | null;
meetingUrl?: string;
cancelLink?: string;
cancelReason?: string | null;
rescheduleLink?: string;
rescheduleReason?: string | null;
ratingUrl?: string;
noShowUrl?: string;
attendeeTimezone?: string;
eventTimeInAttendeeTimezone?: Dayjs;
eventEndTimeInAttendeeTimezone?: Dayjs;
};
// Replaces placeholders like {EVENT_NAME_VARIABLE} with {EVENT_NAME}
function replaceVariablePlaceholders(text: string) {
return text.replace(/\{([A-Z0-9_]+)_VARIABLE}/g, (_, base) => `{${base}}`);
}
const customTemplate = (
text: string,
variables: VariablesType,
locale: string,
timeFormat?: TimeFormat,
isBrandingDisabled?: boolean
) => {
const eventDate = variables.eventDate;
const translatedDate = eventDate
? new Intl.DateTimeFormat(locale, {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
}).format(eventDate.add(dayjs().tz(variables.timeZone).utcOffset(), "minute").toDate())
: "";
let locationString = variables.location || "";
text = replaceVariablePlaceholders(text);
if (text.includes("{LOCATION}")) {
locationString = guessEventLocationType(locationString)?.label || locationString;
}
const cancelLink = variables.cancelLink ?? "";
const rescheduleLink = variables.rescheduleLink ?? "";
const currentTimeFormat = timeFormat || TimeFormat.TWELVE_HOUR;
const attendeeNameWords = variables.attendeeName?.trim().split(" ");
const attendeeNameWordCount = attendeeNameWords?.length ?? 0;
const attendeeFirstName = variables.attendeeFirstName
? variables.attendeeFirstName
: (attendeeNameWords?.[0] ?? "");
const attendeeLastName = variables.attendeeLastName
? variables.attendeeLastName
: attendeeNameWordCount > 1
? attendeeNameWords![attendeeNameWordCount - 1]
: "";
let dynamicText = text
.replaceAll("{EVENT_NAME}", variables.eventName || "")
.replaceAll("{ORGANIZER}", variables.organizerName || "")
.replaceAll("{ATTENDEE}", variables.attendeeName || "")
.replaceAll("{ORGANIZER_NAME}", variables.organizerName || "") //old variable names
.replaceAll("{ATTENDEE_NAME}", variables.attendeeName || "") //old variable names
.replaceAll("{ATTENDEE_FIRST_NAME}", attendeeFirstName)
.replaceAll("{ATTENDEE_LAST_NAME}", attendeeLastName)
.replaceAll("{EVENT_DATE}", translatedDate)
.replaceAll("{EVENT_TIME}", variables.eventDate?.format(currentTimeFormat) || "")
.replaceAll("{START_TIME}", variables.eventDate?.format(currentTimeFormat) || "")
.replaceAll("{EVENT_END_TIME}", variables.eventEndTime?.format(currentTimeFormat) || "")
.replaceAll("{LOCATION}", locationString)
.replaceAll("{ADDITIONAL_NOTES}", variables.additionalNotes || "")
.replaceAll("{ATTENDEE_EMAIL}", variables.attendeeEmail || "")
.replaceAll("{TIMEZONE}", variables.timeZone || "")
.replaceAll("{CANCEL_URL}", cancelLink)
.replaceAll("{CANCELLATION_REASON}", variables.cancelReason || "")
.replaceAll("{RESCHEDULE_URL}", rescheduleLink)
.replaceAll("{RESCHEDULE_REASON}", variables.rescheduleReason || "")
.replaceAll("{MEETING_URL}", variables.meetingUrl || "")
.replaceAll("{RATING_URL}", variables.ratingUrl || "")
.replaceAll("{NO_SHOW_URL}", variables.noShowUrl || "")
.replaceAll("{ATTENDEE_TIMEZONE}", variables.attendeeTimezone || "")
.replaceAll(
"{EVENT_START_TIME_IN_ATTENDEE_TIMEZONE}",
variables.eventTimeInAttendeeTimezone?.format(currentTimeFormat) || ""
)
.replaceAll(
"{EVENT_END_TIME_IN_ATTENDEE_TIMEZONE}",
variables.eventEndTimeInAttendeeTimezone?.format(currentTimeFormat) || ""
);
const customInputvariables = dynamicText.match(/\{(.+?)}/g)?.map((variable) => {
return variable.replace("{", "").replace("}", "");
});
// event date/time with formatting
customInputvariables?.forEach((variable) => {
if (
variable.startsWith("EVENT_DATE_") ||
variable.startsWith("EVENT_TIME_") ||
variable.startsWith("START_TIME_")
) {
const dateFormat = variable.substring(11, text.length);
const formattedDate = variables.eventDate?.locale(locale).format(dateFormat);
dynamicText = dynamicText.replace(`{${variable}}`, formattedDate || "");
return;
}
if (variable.startsWith("EVENT_END_TIME_")) {
const dateFormat = variable.substring(15, text.length);
const formattedDate = variables.eventEndTime?.locale(locale).format(dateFormat);
dynamicText = dynamicText.replace(`{${variable}}`, formattedDate || "");
return;
}
// handle custom variables from form/booking responses
if (variables.responses) {
Object.keys(variables.responses).forEach((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 (isFoundTemplateVariableValid && variables.responses) {
const response = variables.responses[customInput];
if (response?.value !== undefined) {
const responseValue = response.value;
const valueString = Array.isArray(responseValue)
? responseValue.join(", ")
: String(responseValue);
dynamicText = dynamicText.replace(`{${foundVariableInTemplate}}`, valueString);
}
}
});
}
});
const branding = !isBrandingDisabled ? `<br><br>_<br><br>Scheduling by ${APP_NAME}` : "";
const textHtml = `<body style="white-space: pre-wrap;">${dynamicText}${branding}</body>`;
return { text: dynamicText, html: textHtml };
};
export default customTemplate;