Files
calendar/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts
T
Udit TakkarGitHubUdit Takkarunknown <>Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>Udit 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

348 lines
11 KiB
TypeScript

import { BookingSeatRepository } from "@calcom/features/bookings/repositories/BookingSeatRepository";
import type { CreditCheckFn } from "@calcom/features/ee/billing/credit-service";
import {
isAttendeeAction,
isCalAIAction,
isEmailAction,
isSMSAction,
isSMSOrWhatsappAction,
isWhatsappAction,
} from "@calcom/features/ee/workflows/lib/actionHelperFunctions";
import { EmailWorkflowService } from "@calcom/features/ee/workflows/lib/service/EmailWorkflowService";
import { WorkflowService } from "@calcom/features/ee/workflows/lib/service/WorkflowService";
import type { Workflow, WorkflowStep } from "@calcom/features/ee/workflows/lib/types";
import { WorkflowReminderRepository } from "@calcom/features/ee/workflows/repositories/WorkflowReminderRepository";
import { formatCalEventExtended } from "@calcom/lib/formatCalendarEvent";
import { withReporting } from "@calcom/lib/sentryWrapper";
import { getTranslation } from "@calcom/i18n/server";
import { checkSMSRateLimit } from "@calcom/lib/smsLockState";
import { prisma } from "@calcom/prisma";
import { type SchedulingType, WorkflowActions, WorkflowTriggerEvents } from "@calcom/prisma/enums";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { BookingInfo, FormSubmissionData } from "../types";
import type { ScheduleTextReminderAction } from "./smsReminderManager";
export type WorkflowContextData =
| { evt: BookingInfo; formData?: never }
| {
evt?: never;
formData: FormSubmissionData;
};
export type ExtendedCalendarEvent = Omit<CalendarEvent, "bookerUrl"> & {
metadata?: { videoCallUrl: string | undefined };
eventType: {
slug: string;
schedulingType?: SchedulingType | null;
hosts?: { user: { email: string; destinationCalendar?: { primaryEmail: string | null } | null } }[];
};
rescheduleReason?: string | null;
cancellationReason?: string | null;
bookerUrl: string;
};
type ProcessWorkflowStepParams = (
| { calendarEvent: ExtendedCalendarEvent; formData?: never }
| {
calendarEvent?: never;
formData: FormSubmissionData;
}
) & {
smsReminderNumber: string | null;
emailAttendeeSendToOverride?: string;
hideBranding?: boolean;
seatReferenceUid?: string;
};
export type ScheduleWorkflowRemindersArgs = ProcessWorkflowStepParams & {
workflows: Workflow[];
isDryRun?: boolean;
creditCheckFn: CreditCheckFn;
};
const getReminderPhoneNumber = async (
action: WorkflowActions,
seatReferenceUid: string | undefined,
smsReminderNumber: string | null,
stepSendTo: string | null
) => {
const isAttendeeAction =
action === WorkflowActions.SMS_ATTENDEE || action === WorkflowActions.WHATSAPP_ATTENDEE;
if (!isAttendeeAction) {
return stepSendTo;
}
if (seatReferenceUid) {
const bookingSeatRepository = new BookingSeatRepository(prisma);
const seatAttendeeData =
await bookingSeatRepository.getByReferenceUidWithAttendeeDetails(seatReferenceUid);
return seatAttendeeData?.attendee?.phoneNumber || smsReminderNumber;
}
return smsReminderNumber;
};
const processWorkflowStep = async (
workflow: Workflow,
step: WorkflowStep,
{
smsReminderNumber,
calendarEvent,
emailAttendeeSendToOverride,
hideBranding,
seatReferenceUid,
formData,
}: ProcessWorkflowStepParams,
creditCheckFn: CreditCheckFn
) => {
if (!step?.verifiedAt) return;
const evt = calendarEvent ? formatCalEventExtended(calendarEvent) : undefined;
if (!evt && !formData) return;
const contextData: WorkflowContextData = evt ? { evt } : { formData: formData as FormSubmissionData };
if (isSMSOrWhatsappAction(step.action)) {
await checkSMSRateLimit({
identifier: `sms:${workflow.teamId ? "team:" : "user:"}${workflow.teamId || workflow.userId}`,
rateLimitingType: "sms",
});
}
// Common parameters for all scheduling functions
const scheduleFunctionParams = WorkflowService.generateCommonScheduleFunctionParams({
workflow,
workflowStep: step,
seatReferenceUid: seatReferenceUid,
creditCheckFn,
});
if (isSMSAction(step.action)) {
const { scheduleSMSReminder } = await import("./smsReminderManager");
const sendTo = await getReminderPhoneNumber(
step.action,
seatReferenceUid,
smsReminderNumber,
step.sendTo
);
await scheduleSMSReminder({
...scheduleFunctionParams,
reminderPhone: sendTo,
action: step.action as ScheduleTextReminderAction,
message: step.reminderBody || "",
sender: step.sender,
autoTranslateEnabled: step.autoTranslateEnabled,
sourceLocale: step.sourceLocale,
isVerificationPending: step.numberVerificationPending,
...contextData,
});
} else if (isEmailAction(step.action)) {
const { scheduleEmailReminder } = await import("./emailReminderManager");
if (!evt && step.action === WorkflowActions.EMAIL_HOST) {
// EMAIL_HOST is not supported for form triggers
return;
}
const workflowReminderRepository = new WorkflowReminderRepository(prisma);
const bookingSeatRepository = new BookingSeatRepository(prisma);
const emailWorkflowService = new EmailWorkflowService(workflowReminderRepository, bookingSeatRepository);
const emailParams = await emailWorkflowService.generateParametersToBuildEmailWorkflowContent({
evt,
workflowStep: step,
workflow,
emailAttendeeSendToOverride,
formData,
commonScheduleFunctionParams: scheduleFunctionParams,
hideBranding,
});
await scheduleEmailReminder(emailParams);
} else if (isWhatsappAction(step.action)) {
if (!evt) {
// Whatsapp action not not yet supported for form triggers
return;
}
const { scheduleWhatsappReminder } = await import("./whatsappReminderManager");
const sendTo = await getReminderPhoneNumber(
step.action,
seatReferenceUid,
smsReminderNumber,
step.sendTo
);
await scheduleWhatsappReminder({
...scheduleFunctionParams,
verifiedAt: step.verifiedAt,
reminderPhone: sendTo,
action: step.action as ScheduleTextReminderAction,
message: step.reminderBody || "",
autoTranslateEnabled: step.autoTranslateEnabled,
sourceLocale: step.sourceLocale,
isVerificationPending: step.numberVerificationPending,
evt,
});
} else if (isCalAIAction(step.action)) {
const { scheduleAIPhoneCall } = await import("./aiPhoneCallManager");
await scheduleAIPhoneCall({
triggerEvent: workflow.trigger,
timeSpan: {
time: workflow.time,
timeUnit: workflow.timeUnit,
},
workflowStepId: step.id,
userId: workflow.userId,
teamId: workflow.teamId,
seatReferenceUid,
submittedPhoneNumber: smsReminderNumber,
verifiedAt: step.verifiedAt,
routedEventTypeId: formData ? formData.routedEventTypeId : null,
...contextData,
});
}
};
const _scheduleWorkflowReminders = async (args: ScheduleWorkflowRemindersArgs) => {
const {
workflows,
smsReminderNumber,
calendarEvent: evt,
emailAttendeeSendToOverride = "",
hideBranding,
seatReferenceUid,
isDryRun = false,
formData,
creditCheckFn,
} = args;
if (isDryRun || !workflows.length) return;
for (const workflow of workflows) {
if (workflow.steps.length === 0) continue;
for (const step of workflow.steps) {
if (
// These tasks currently write the entire payload in the task
(workflow.trigger === WorkflowTriggerEvents.BEFORE_EVENT ||
workflow.trigger === WorkflowTriggerEvents.AFTER_EVENT) &&
isEmailAction(step.action) &&
evt
) {
await WorkflowService.scheduleLazyEmailWorkflow({
evt,
workflowStepId: step.id,
workflowTriggerEvent: workflow.trigger,
workflow,
seatReferenceId: args.seatReferenceUid,
});
continue;
}
await processWorkflowStep(
workflow,
step,
{
emailAttendeeSendToOverride,
smsReminderNumber,
hideBranding,
seatReferenceUid,
...(evt ? { calendarEvent: evt } : { formData }),
},
creditCheckFn
);
}
}
};
export interface SendCancelledRemindersArgs {
workflows: Workflow[];
smsReminderNumber: string | null;
evt: ExtendedCalendarEvent;
hideBranding?: boolean;
creditCheckFn: CreditCheckFn;
}
const _sendCancelledReminders = async (args: SendCancelledRemindersArgs) => {
const { smsReminderNumber, evt, workflows, hideBranding, creditCheckFn } = args;
if (!workflows.length) return;
for (const workflow of workflows) {
if (workflow.trigger !== WorkflowTriggerEvents.EVENT_CANCELLED) continue;
for (const step of workflow.steps) {
await processWorkflowStep(
workflow,
step,
{
smsReminderNumber,
hideBranding,
calendarEvent: evt,
},
creditCheckFn
);
}
}
};
const _cancelScheduledMessagesAndScheduleEmails = async ({
teamId,
userIdsWithNoCredits,
}: {
teamId?: number | null;
userIdsWithNoCredits: number[];
}) => {
const { WorkflowReminderRepository } = await import(
"@calcom/features/ee/workflows/repositories/WorkflowReminderRepository"
);
const workflowReminderRepository = new WorkflowReminderRepository(prisma);
const scheduledMessages = await workflowReminderRepository.findScheduledMessagesToCancel({
teamId,
userIdsWithNoCredits,
});
const [twilio, { sendOrScheduleWorkflowEmails }] = await Promise.all([
import("./providers/twilioProvider"),
import("./providers/emailProvider"),
]);
await Promise.allSettled(scheduledMessages.map((msg) => twilio.cancelSMS(msg.referenceId ?? "")));
await Promise.allSettled(
scheduledMessages.map(async (msg) => {
if (msg.workflowStep?.action && isAttendeeAction(msg.workflowStep.action)) {
const messageBody = await twilio.getMessageBody(msg.referenceId ?? "");
const sendTo = msg.booking?.attendees?.[0];
if (sendTo) {
const t = await getTranslation(sendTo.locale ?? "en", "common");
await sendOrScheduleWorkflowEmails({
to: [sendTo.email],
subject: t("notification_about_your_booking"),
html: messageBody,
replyTo: msg.booking?.user?.email ?? "",
sendAt: msg.scheduledDate,
referenceUid: msg.uuid || undefined,
});
}
}
})
);
await workflowReminderRepository.updateRemindersToEmail({
reminderIds: scheduledMessages.map((msg) => msg.id),
});
};
// Export functions wrapped with withReporting
export const scheduleWorkflowReminders = withReporting(
_scheduleWorkflowReminders,
"scheduleWorkflowReminders"
);
export const sendCancelledReminders = withReporting(_sendCancelledReminders, "sendCancelledReminders");
export const cancelScheduledMessagesAndScheduleEmails = withReporting(
_cancelScheduledMessagesAndScheduleEmails,
"cancelScheduledMessagesAndScheduleEmails"
);