Files
calendar/packages/features/ee/workflows/lib/reminders/emailReminderManager.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

289 lines
7.5 KiB
TypeScript

import dayjs from "@calcom/dayjs";
import { BookingSeatRepository } from "@calcom/features/bookings/repositories/BookingSeatRepository";
import { EmailWorkflowService } from "@calcom/features/ee/workflows/lib/service/EmailWorkflowService";
import { WorkflowService } from "@calcom/features/ee/workflows/lib/service/WorkflowService";
import { WorkflowReminderRepository } from "@calcom/features/ee/workflows/repositories/WorkflowReminderRepository";
import tasker from "@calcom/features/tasker";
import logger from "@calcom/lib/logger";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import prisma from "@calcom/prisma";
import type { TimeUnit } from "@calcom/prisma/enums";
import { WorkflowMethods, WorkflowTemplates, WorkflowTriggerEvents } from "@calcom/prisma/enums";
import type { BookingInfo, ScheduleEmailReminderAction, FormSubmissionData } from "../types";
import { sendOrScheduleWorkflowEmails } from "./providers/emailProvider";
import type { WorkflowContextData } from "./reminderScheduler";
import type { VariablesType } from "./templates/customTemplate";
import customTemplate, { transformRoutingFormResponsesToVariableFormat } from "./templates/customTemplate";
const log = logger.getSubLogger({ prefix: ["[emailReminderManager]"] });
export type ScheduleReminderArgs = {
triggerEvent: WorkflowTriggerEvents;
timeSpan: {
time: number | null;
timeUnit: TimeUnit | null;
};
template?: WorkflowTemplates;
sender?: string | null;
workflowStepId?: number;
seatReferenceUid?: string;
} & WorkflowContextData;
type scheduleEmailReminderArgs = ScheduleReminderArgs & {
sendTo: string[];
action: ScheduleEmailReminderAction;
emailSubject?: string;
emailBody?: string;
hideBranding?: boolean;
includeCalendarEvent?: boolean;
verifiedAt: Date | null;
autoTranslateEnabled?: boolean;
sourceLocale?: string | null;
};
type SendEmailReminderParams = {
mailData: {
subject: string;
html: string;
replyTo?: string;
attachments?: {
content: string;
filename: string;
contentType: string;
disposition: string;
}[];
sender?: string | null;
};
sendTo: string[];
triggerEvent: WorkflowTriggerEvents;
scheduledDate?: Date | null;
uid?: string;
workflowStepId?: number;
seatReferenceUid?: string;
};
const sendOrScheduleWorkflowEmailWithReminder = async (params: SendEmailReminderParams) => {
const { mailData, sendTo, scheduledDate, uid, workflowStepId } = params;
let reminderUid;
if (scheduledDate) {
const reminder = await prisma.workflowReminder.create({
data: {
bookingUid: uid,
workflowStepId,
method: WorkflowMethods.EMAIL,
scheduledDate,
scheduled: true,
},
});
reminderUid = reminder.uuid;
}
await sendOrScheduleWorkflowEmails({
...mailData,
to: sendTo,
sendAt: scheduledDate,
referenceUid: reminderUid ?? undefined,
});
};
export const scheduleEmailReminder = async (args: scheduleEmailReminderArgs) => {
const { verifiedAt, workflowStepId } = args;
if (!verifiedAt) {
log.warn(`Workflow step ${workflowStepId} not yet verified`);
return;
}
if (args.evt) {
await scheduleEmailReminderForEvt(args);
} else {
await scheduleEmailReminderForForm(args);
}
};
const scheduleEmailReminderForEvt = async (args: scheduleEmailReminderArgs & { evt: BookingInfo }) => {
const {
evt,
triggerEvent,
timeSpan,
template,
sender,
workflowStepId,
seatReferenceUid,
sendTo,
emailSubject = "",
emailBody = "",
hideBranding,
includeCalendarEvent,
action,
autoTranslateEnabled,
sourceLocale,
} = args;
const uid = evt.uid as string;
const scheduledDate = WorkflowService.processWorkflowScheduledDate({
workflowTriggerEvent: triggerEvent,
time: timeSpan.time,
timeUnit: timeSpan.timeUnit,
evt,
});
if (
scheduledDate &&
triggerEvent === WorkflowTriggerEvents.BEFORE_EVENT &&
dayjs(scheduledDate).isBefore(dayjs())
) {
log.debug(
`Skipping reminder for workflow step ${workflowStepId} - scheduled date ${scheduledDate} is in the past`
);
return;
}
const workflowReminderRepository = new WorkflowReminderRepository(prisma);
const bookingSeatRepository = new BookingSeatRepository(prisma);
const emailWorkflowService = new EmailWorkflowService(workflowReminderRepository, bookingSeatRepository);
const mailData = await emailWorkflowService.generateEmailPayloadForEvtWorkflow({
evt,
sendTo,
seatReferenceUid,
hideBranding,
emailSubject,
emailBody,
sender: sender || "",
action,
template,
includeCalendarEvent,
triggerEvent,
workflowStepId,
autoTranslateEnabled,
sourceLocale,
});
await sendOrScheduleWorkflowEmailWithReminder({
mailData,
sendTo,
triggerEvent,
scheduledDate,
uid,
workflowStepId,
seatReferenceUid,
});
};
// sends all immediately, no scheduling needed
const scheduleEmailReminderForForm = async (
args: scheduleEmailReminderArgs & {
formData: FormSubmissionData;
}
) => {
const {
formData,
triggerEvent,
sender,
workflowStepId,
sendTo,
emailSubject = "",
emailBody = "",
hideBranding,
} = args;
const emailContent = {
emailSubject,
emailBody: `<body style="white-space: pre-wrap;">${emailBody}</body>`,
};
if (emailBody) {
const timeFormat = getTimeFormatStringFromUserTimeFormat(formData.user.timeFormat);
const variables: VariablesType = {
responses: transformRoutingFormResponsesToVariableFormat(formData.responses),
};
const emailSubjectTemplate = customTemplate(emailSubject, variables, formData.user.locale, timeFormat);
emailContent.emailSubject = emailSubjectTemplate.text;
emailContent.emailBody = customTemplate(
emailBody,
variables,
formData.user.locale,
timeFormat,
hideBranding
).html;
}
// Allows debugging generated email content without waiting for sendgrid to send emails
log.debug(`Sending Email for trigger ${triggerEvent}`, JSON.stringify(emailContent));
const mailData = {
subject: emailContent.emailSubject,
html: emailContent.emailBody,
sender,
};
await sendOrScheduleWorkflowEmailWithReminder({
mailData,
sendTo,
triggerEvent,
workflowStepId,
scheduledDate: null,
});
};
export const deleteScheduledEmailReminder = async (reminderId: number) => {
const workflowReminder = await prisma.workflowReminder.findUnique({
where: {
id: reminderId,
},
});
if (!workflowReminder) {
console.error("Workflow reminder not found");
return;
}
const { uuid, referenceId } = workflowReminder;
if (uuid) {
try {
const taskId = await tasker.cancelWithReference(uuid, "sendWorkflowEmails");
if (taskId) {
await prisma.workflowReminder.delete({
where: {
id: reminderId,
},
});
return;
}
} catch (error) {
log.error(`Error canceling/deleting reminder with tasker. Error: ${error}`);
}
}
/**
* @deprecated only needed for SendGrid, use SMTP with tasker instead
*/
try {
if (!referenceId) {
await prisma.workflowReminder.delete({
where: {
id: reminderId,
},
});
return;
}
await prisma.workflowReminder.update({
where: {
id: reminderId,
},
data: {
cancelled: true,
},
});
} catch (error) {
log.error(`Error canceling reminder with error ${error}`);
}
};