Files
calendar/packages/features/ee/workflows/lib/scheduleBookingReminders.ts
T
Udit TakkarGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>Udit TakkarUdit Takkar
4081d11fbe feat: workflow auto translation (#27087)
* feat: workflow auto translation

* tests: add unit tests

* refactor: tests and workflow

* fix: type err

* fix: type err

* fix: remove redundant index on WorkflowStepTranslation

The @@index on [workflowStepId, field, targetLocale] duplicates the @@unique
constraint on the same columns. A unique index already provides efficient
lookups, so the separate @@index adds storage overhead and write latency
without benefit.

Addresses Cubic AI review feedback (confidence 9/10).

Co-Authored-By: unknown <>

* fix: correct locale mapping when translation API returns null

Map translations with their corresponding locales before filtering to
preserve correct locale-to-translation associations. Previously, filtering
out null translations would reindex the array, causing incorrect locale
mappings when any translation in the batch failed.

Also fixes pre-existing lint warnings:
- Move exports to end of file
- Add explicit return type to processTranslations
- Replace ternary with if-else for upsertMany selection

Co-Authored-By: udit@cal.com <udit222001@gmail.com>

* fix: address review feedback for workflow auto-translation

- Add change detection before creating translation tasks
- Rename userLocale to sourceLocale in task props for clarity
- Show source language in UI with new translation key
- Extract SUPPORTED_LOCALES to shared translationConstants.ts
- Fix locale mapping bug in translateEventTypeData.ts
- Add WhatsApp translation support
- Abstract translation lookup into shared translationLookup.ts helper
- Restore if-else readability for SCANNING_WORKFLOW_STEPS

Co-authored-by: Udit Takkar <udit.takkar@cal.com>
Co-Authored-By: unknown <>

* fix: update test to use sourceLocale instead of userLocale

Co-Authored-By: unknown <>

* refactor: feedback

* fix: handle first time

* fix: tests

* fix: tests

* fix: address Cubic AI review feedback (confidence 9/10 issues)

- WhatsApp translation: Apply variable substitution using getSMSMessageWithVariables
  and clear contentSid when using translated body to ensure Twilio uses the
  translated text instead of the original template

- update.handler.ts: Change sourceLocale assignment from ?? to || for consistency
  with tasker payload behavior (line 481)

- ITranslationService.ts: Rename methods from plural to singular naming:
  - getWorkflowStepTranslations -> getWorkflowStepTranslation
  - getEventTypeTranslations -> getEventTypeTranslation
  Updated all call sites and tests accordingly

Co-Authored-By: unknown <>

* fix: address Cubic AI review feedback (confidence 9/10+ issues)

- Fix getSMSMessageWithVariables to handle WHATSAPP_ATTENDEE action for
  locale and timezone (confidence 9/10)
- Remove WhatsApp translation feature that set contentSid to undefined
  since Twilio ignores body parameter for WhatsApp and requires
  pre-approved Message Templates (confidence 10/10)

Co-Authored-By: unknown <>

* fix: translatio

* Add tests: packages/features/eventTypeTranslation/repositories/EventTypeTranslationRepository.test.ts

Generated by Paragon from proposal for PR #27087

* Add tests: packages/features/tasker/tasks/translateWorkflowStepData.test.ts

Generated by Paragon from proposal for PR #27087

* chore: nit

* chore: verfied atg

* fix: set sourceLocale for new steps, add shouldDirty to checkbox, remove spec docs

- Set sourceLocale fallback in addedSteps mapping to fix stale detection mismatch
- Add { shouldDirty: true } to autoTranslateEnabled checkbox onChange
- Remove specs/workflow-translation/ directory (planning docs, not for repo)

Co-authored-by: Udit Takkar <udit.07.takkar@gmail.com>
Co-Authored-By: unknown <>

* chore: add specs back

* fix: type error

* fix: type error

* fix: type err

* fix: tests

* refactor: feedback

* fix: type err

* refactor

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit.takkar@cal.com>
Co-authored-by: Udit Takkar <udit.07.takkar@gmail.com>
2026-02-25 01:03:55 +05:30

228 lines
8.7 KiB
TypeScript

import { scheduleAIPhoneCall } from "@calcom/ee/workflows/lib/reminders/aiPhoneCallManager";
import { scheduleEmailReminder } from "@calcom/ee/workflows/lib/reminders/emailReminderManager";
import { scheduleSMSReminder } from "@calcom/ee/workflows/lib/reminders/smsReminderManager";
import { scheduleWhatsappReminder } from "@calcom/ee/workflows/lib/reminders/whatsappReminderManager";
import { CreditService } from "@calcom/features/ee/billing/credit-service";
import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import type { WorkflowStep } from "@calcom/prisma/client";
import type { TimeUnit } from "@calcom/prisma/enums";
import { SchedulingType, WorkflowActions, WorkflowTriggerEvents } from "@calcom/prisma/enums";
import type { CalEventResponses } from "@calcom/types/Calendar";
import type { getBookings } from "./scheduleWorkflowNotifications";
import { verifyEmailSender } from "./verifyEmailSender";
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type Bookings = UnwrapPromise<ReturnType<typeof getBookings>>;
// some parts of scheduleWorkflowReminders (reminderSchedule.ts) is quite similar to this code
// we should consider refactoring this to reuse similar code snippets
export async function scheduleBookingReminders(
bookings: Bookings,
workflowSteps: Partial<WorkflowStep>[],
time: number | null,
timeUnit: TimeUnit | null,
trigger: WorkflowTriggerEvents,
userId: number,
teamId: number | null,
isOrg: boolean
) {
if (!bookings.length) return;
if (trigger !== WorkflowTriggerEvents.BEFORE_EVENT && trigger !== WorkflowTriggerEvents.AFTER_EVENT) return;
const bookerUrl = await getBookerBaseUrl(isOrg ? teamId : null);
const creditService = new CreditService();
//create reminders for all bookings for each workflow step
const promiseSteps = workflowSteps.map(async (step) => {
const promiseScheduleReminders = bookings.map(async (booking) => {
const defaultLocale = "en";
const bookingInfo = {
uid: booking.uid,
bookerUrl,
type: booking.eventType?.slug || "event",
attendees: booking.attendees.map((attendee) => {
return {
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
language: { locale: attendee.locale || defaultLocale },
};
}),
organizer: booking.user
? {
language: { locale: booking.user.locale || defaultLocale },
name: booking.user.name || "",
email: booking?.userPrimaryEmail ?? booking.user.email,
timeZone: booking.user.timeZone,
timeFormat: getTimeFormatStringFromUserTimeFormat(booking.user.timeFormat),
}
: { name: "", email: "", timeZone: "", language: { locale: "" } },
startTime: booking.startTime?.toISOString(),
endTime: booking.endTime?.toISOString(),
title: booking.title,
language: { locale: booking?.user?.locale || defaultLocale },
hideOrganizerEmail: booking.eventType?.hideOrganizerEmail,
eventType: {
slug: booking.eventType?.slug || "",
schedulingType: booking.eventType?.schedulingType,
hosts: booking.eventType?.hosts,
},
metadata: booking.metadata,
customReplyToEmail: booking.eventType?.customReplyToEmail,
responses: booking.responses as CalEventResponses | null,
};
if (
step.action === WorkflowActions.EMAIL_HOST ||
step.action === WorkflowActions.EMAIL_ATTENDEE ||
step.action === WorkflowActions.EMAIL_ADDRESS
) {
let sendTo: string[] = [];
switch (step.action) {
case WorkflowActions.EMAIL_HOST: {
sendTo = [bookingInfo.organizer?.email];
const schedulingType = bookingInfo.eventType.schedulingType;
const hosts = bookingInfo.eventType.hosts
?.filter((host) => bookingInfo.attendees.some((attendee) => attendee.email === host.user.email))
.map(({ user }) => user.destinationCalendar?.primaryEmail ?? user.email);
if (
hosts &&
(schedulingType === SchedulingType.ROUND_ROBIN || schedulingType === SchedulingType.COLLECTIVE)
) {
sendTo = sendTo.concat(hosts);
}
break;
}
case WorkflowActions.EMAIL_ATTENDEE:
sendTo = bookingInfo.attendees.map((attendee) => attendee.email);
break;
case WorkflowActions.EMAIL_ADDRESS:
await verifyEmailSender(step.sendTo || "", userId, teamId);
sendTo = [step.sendTo || ""];
break;
}
await scheduleEmailReminder({
evt: bookingInfo,
triggerEvent: trigger,
action: step.action,
timeSpan: {
time,
timeUnit,
},
sendTo,
emailSubject: step.emailSubject || "",
emailBody: step.reminderBody || "",
template: step.template,
sender: step.sender,
workflowStepId: step.id,
verifiedAt: step?.verifiedAt ?? null,
autoTranslateEnabled: step.autoTranslateEnabled,
sourceLocale: step.sourceLocale,
});
} else if (step.action === WorkflowActions.SMS_NUMBER && step.sendTo) {
await scheduleSMSReminder({
evt: bookingInfo,
reminderPhone: step.sendTo,
triggerEvent: trigger,
action: step.action,
timeSpan: {
time,
timeUnit,
},
message: step.reminderBody || "",
workflowStepId: step.id,
template: step.template,
sender: step.sender,
userId: userId,
teamId: teamId,
verifiedAt: step?.verifiedAt ?? null,
creditCheckFn: creditService.hasAvailableCredits.bind(creditService),
});
} else if (step.action === WorkflowActions.WHATSAPP_NUMBER && step.sendTo) {
await scheduleWhatsappReminder({
evt: bookingInfo,
reminderPhone: step.sendTo,
triggerEvent: trigger,
action: step.action,
timeSpan: {
time,
timeUnit,
},
message: step.reminderBody || "",
workflowStepId: step.id || 0,
template: step.template,
userId: userId,
teamId: teamId,
verifiedAt: step?.verifiedAt ?? null,
creditCheckFn: creditService.hasAvailableCredits.bind(creditService),
});
} else if (booking.smsReminderNumber) {
if (step.action === WorkflowActions.SMS_ATTENDEE) {
await scheduleSMSReminder({
evt: bookingInfo,
reminderPhone: booking.smsReminderNumber,
triggerEvent: trigger,
action: step.action,
timeSpan: {
time,
timeUnit,
},
message: step.reminderBody || "",
workflowStepId: step.id,
template: step.template,
sender: step.sender,
userId: userId,
teamId: teamId,
verifiedAt: step?.verifiedAt ?? null,
autoTranslateEnabled: step.autoTranslateEnabled,
sourceLocale: step.sourceLocale,
creditCheckFn: creditService.hasAvailableCredits.bind(creditService),
});
} else if (step.action === WorkflowActions.WHATSAPP_ATTENDEE) {
await scheduleWhatsappReminder({
evt: bookingInfo,
reminderPhone: booking.smsReminderNumber,
triggerEvent: trigger,
action: step.action,
timeSpan: {
time,
timeUnit,
},
message: step.reminderBody || "",
workflowStepId: step.id,
template: step.template,
sender: step.sender,
userId: userId,
teamId: teamId,
verifiedAt: step?.verifiedAt ?? null,
autoTranslateEnabled: step.autoTranslateEnabled,
sourceLocale: step.sourceLocale,
creditCheckFn: creditService.hasAvailableCredits.bind(creditService),
});
}
} else if (step.action === WorkflowActions.CAL_AI_PHONE_CALL) {
await scheduleAIPhoneCall({
evt: bookingInfo,
triggerEvent: trigger,
timeSpan: {
time,
timeUnit,
},
workflowStepId: step.id,
userId,
teamId,
verifiedAt: step?.verifiedAt ?? null,
submittedPhoneNumber: booking.smsReminderNumber,
routedEventTypeId: null,
});
}
});
await Promise.all(promiseScheduleReminders);
});
return Promise.all(promiseSteps);
}