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>
This commit is contained in:
authored and GitHub committed 2026-02-25 01:03:55 +05:30
1 parent f883dee084
commit 4081d11fbe
53 files changed
+2241 -149

No files matched your search

@@ -468,7 +468,7 @@ describe("Slots 2024-04-15 Endpoints", () => {
it("should do a booking and slot should not be available at that time", async () => {
const startTime = "2050-09-05T11:00:00.000Z";
const booking = await bookingsRepositoryFixture.create({
uid: `booking-uid-${randomString()}`,
uid: `booking-uid-${eventTypeId}`,
title: "booking title",
startTime,
endTime: "2050-09-05T12:00:00.000Z",
@@ -515,7 +515,7 @@ describe("Slots 2024-04-15 Endpoints", () => {
it("should do a booking for seated event and slot should show attendees count and bookingUid", async () => {
const startTime = "2050-09-05T11:00:00.000Z";
const booking = await bookingsRepositoryFixture.create({
uid: `booking-uid-${randomString()}`,
uid: `booking-uid-seated-${seatedEventTypeId}-${randomString()}`,
title: "booking title",
startTime,
endTime: "2050-09-05T12:00:00.000Z",
@@ -549,7 +549,7 @@ describe("Slots 2024-04-15 Endpoints", () => {
});
bookingSeatsRepositoryFixture.create({
referenceUid: randomString(),
referenceUid: `seat-${randomString()}`,
data: {},
booking: {
connect: {
@@ -598,7 +598,7 @@ describe("Slots 2024-04-15 Endpoints", () => {
it("should do a booking for seated event and slot should show attendees count and bookingUid in range format", async () => {
const startTime = "2050-09-05T11:00:00.000Z";
const booking = await bookingsRepositoryFixture.create({
uid: `booking-uid-${randomString()}`,
uid: `booking-uid-seated-${seatedEventTypeId}-${randomString()}`,
title: "booking title",
startTime,
endTime: "2050-09-05T12:00:00.000Z",
@@ -632,7 +632,7 @@ describe("Slots 2024-04-15 Endpoints", () => {
});
bookingSeatsRepositoryFixture.create({
referenceUid: randomString(),
referenceUid: `seat-${randomString()}`,
data: {},
booking: {
connect: {
@@ -787,7 +787,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
it("should do a booking and slot should not be available at that time", async () => {
const startTime = "2050-09-05T11:00:00.000Z";
const booking = await bookingsRepositoryFixture.create({
uid: `booking-uid-${randomString()}`,
uid: `booking-uid-${eventTypeId}`,
title: "booking title",
startTime,
endTime: "2050-09-05T12:00:00.000Z",
@@ -832,7 +832,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
it("should do a booking for seated event and slot should show attendees count and bookingUid", async () => {
const startTime = "2050-09-05T11:00:00.000Z";
const booking = await bookingsRepositoryFixture.create({
uid: `booking-uid-${randomString()}`,
uid: `booking-uid-seated-${seatedEventType.id}-${randomString()}`,
title: "booking title",
startTime,
endTime: "2050-09-05T12:00:00.000Z",
@@ -866,7 +866,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
});
bookingSeatsRepositoryFixture.create({
referenceUid: randomString(),
referenceUid: `seat-${randomString()}`,
data: {},
booking: {
connect: {
@@ -961,7 +961,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
it("should do a booking for seated event and slot should show attendees count and bookingUid and return range format", async () => {
const startTime = "2050-09-05T11:00:00.000Z";
const booking = await bookingsRepositoryFixture.create({
uid: `booking-uid-${randomString()}`,
uid: `booking-uid-seated-range-${seatedEventType.id}-${randomString()}`,
title: "booking title",
startTime,
endTime: "2050-09-05T12:00:00.000Z",
@@ -995,7 +995,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
});
bookingSeatsRepositoryFixture.create({
referenceUid: randomString(),
referenceUid: `seat-${randomString()}`,
data: {},
booking: {
connect: {
@@ -1348,7 +1348,8 @@ describe("Slots 2024-09-04 Endpoints", () => {
advanceTo(newDate);
const slotDuration = 60;
const slotStartTime = "2050-09-05T10:00:00.000Z";
// Use 2050-09-06 to avoid overlap with other tests that reserve 2050-09-05
const slotStartTime = "2050-09-06T09:00:00.000Z";
const reserveResponse = await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.send({
@@ -1377,7 +1378,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
const response = await request(app.getHttpServer())
.get(
`/v2/slots?eventTypeId=${variableLengthEventType.id}&start=2050-09-05&end=2050-09-09&duration=60`
`/v2/slots?eventTypeId=${variableLengthEventType.id}&start=2050-09-05&end=2050-09-10&duration=60`
)
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(200);
@@ -1390,10 +1391,10 @@ describe("Slots 2024-09-04 Endpoints", () => {
const days = Object.keys(slots);
expect(days.length).toEqual(5);
const expectedSlotsUTC2050_09_05 = expectedSlotsUTC["2050-09-05"].filter(
const expectedSlotsUTC2050_09_06 = expectedSlotsUTC["2050-09-06"].filter(
(slot) => slot.start !== slotStartTime
);
expect(slots).toEqual({ ...expectedSlotsUTC, "2050-09-05": expectedSlotsUTC2050_09_05 });
expect(slots).toEqual({ ...expectedSlotsUTC, "2050-09-06": expectedSlotsUTC2050_09_06 });
const dbSlot = await selectedSlotRepositoryFixture.getByUid(
responseReservedVariableSlot.reservationUid
@@ -1409,7 +1410,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
});
it("request slot contains already existing reserved slot", async () => {
// Try to reserve 9:45-12:45 when 10:00-11:00 is taken
// Try to reserve 8:45-11:45 when 09:00-10:00 on 2050-09-06 is taken
const newSlotStart = DateTime.fromISO(responseReservedVariableSlot.slotStart)
.minus({ minutes: 15 })
.toISO();
@@ -114,7 +114,11 @@ export class WorkflowsRepository {
) {
await updateWorkflow({
ctx: {
user: { ...user, locale: user?.locale ?? "en" },
user: {
...user,
locale: user?.locale ?? "en",
organizationId: user.profiles?.[0]?.organization?.id ?? null,
},
prisma: this.dbWrite.prisma as unknown as PrismaClient,
},
input: data,
@@ -132,7 +136,11 @@ export class WorkflowsRepository {
) {
await updateWorkflow({
ctx: {
user: { ...user, locale: user?.locale ?? "en" },
user: {
...user,
locale: user?.locale ?? "en",
organizationId: user.profiles?.[0]?.organization?.id ?? null,
},
prisma: this.dbWrite.prisma as unknown as PrismaClient,
},
input: data,
@@ -178,6 +178,8 @@ export default function WorkflowDetailsPage(props: Props) {
verifiedAt: SCANNING_WORKFLOW_STEPS ? null : new Date(),
agentId: null,
inboundAgentId: null,
autoTranslateEnabled: false,
sourceLocale: null,
};
steps?.push(step);
form.setValue("steps", steps);
@@ -1,23 +1,22 @@
import { type TFunction } from "i18next";
import type { TFunction } from "i18next";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import type { Dispatch, SetStateAction } from "react";
import { useEffect, useRef, useState, useCallback } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { UseFormReturn } from "react-hook-form";
import { Controller, useWatch } from "react-hook-form";
import "react-phone-number-input/style.css";
import type { RetellAgentWithDetails } from "@calcom/features/calAIPhone/providers/retellAI";
import { Dialog } from "@calcom/features/components/controlled-dialog";
import PhoneInput from "@calcom/web/components/phone-input";
import {
isSMSAction,
isWhatsappAction,
getTemplateBodyForAction,
shouldScheduleEmailReminder,
isSMSOrWhatsappAction,
hasCalAIAction,
isCalAIAction,
isFormTrigger,
hasCalAIAction,
isSMSAction,
isSMSOrWhatsappAction,
isWhatsappAction,
shouldScheduleEmailReminder,
} from "@calcom/features/ee/workflows/lib/actionHelperFunctions";
import { DYNAMIC_TEXT_VARIABLES } from "@calcom/features/ee/workflows/lib/constants";
import {
@@ -27,6 +26,7 @@ import {
import emailRatingTemplate from "@calcom/features/ee/workflows/lib/reminders/templates/emailRatingTemplate";
import emailReminderTemplate from "@calcom/features/ee/workflows/lib/reminders/templates/emailReminderTemplate";
import type { FormValues } from "@calcom/features/ee/workflows/lib/types";
import PhoneInput from "@calcom/web/components/phone-input";
import "@calcom/features/ee/workflows/style/styles.css";
import { SENDER_ID, SENDER_NAME } from "@calcom/lib/constants";
import { formatPhoneNumber } from "@calcom/lib/formatPhoneNumber";
@@ -48,7 +48,7 @@ import { trpc } from "@calcom/trpc/react";
import classNames from "@calcom/ui/classNames";
import { Badge, InfoBadge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import { DialogContent, DialogFooter, DialogClose } from "@calcom/ui/components/dialog";
import { DialogClose, DialogContent, DialogFooter } from "@calcom/ui/components/dialog";
import {
Dropdown,
DropdownItem,
@@ -56,26 +56,26 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@calcom/ui/components/dropdown";
import { AddVariablesDropdown } from "@calcom/ui/components/editor";
import { Editor } from "@calcom/ui/components/editor";
import { CheckboxField } from "@calcom/ui/components/form";
import { EmailField } from "@calcom/ui/components/form";
import { TextArea } from "@calcom/ui/components/form";
import { Input } from "@calcom/ui/components/form";
import { Label } from "@calcom/ui/components/form";
import { TextField } from "@calcom/ui/components/form";
import { Select } from "@calcom/ui/components/form";
import { MultiSelectCheckbox } from "@calcom/ui/components/form";
import { AddVariablesDropdown, Editor } from "@calcom/ui/components/editor";
import type { MultiSelectCheckboxesOptionType as Option } from "@calcom/ui/components/form";
import {
CheckboxField,
EmailField,
Input,
Label,
MultiSelectCheckbox,
Select,
TextArea,
TextField,
} from "@calcom/ui/components/form";
import { CircleHelpIcon, InfoIcon, PhoneIcon } from "@coss/ui/icons";
import { SkeletonText } from "@calcom/ui/components/skeleton";
import { showToast } from "@calcom/ui/components/toast";
import { useHasPaidPlan, useHasActiveTeamPlan } from "@calcom/web/modules/billing/hooks/useHasPaidPlan";
import { useHasActiveTeamPlan, useHasPaidPlan } from "@calcom/web/modules/billing/hooks/useHasPaidPlan";
import { AgentConfigurationSheet }from "./agent-configuration/AgentConfigurationSheet";
import { TestPhoneCallDialog } from "./TestPhoneCallDialog";
import { TimeTimeUnitInput } from "./TimeTimeUnitInput";
import { WebCallDialog } from "./WebCallDialog";
import { AgentConfigurationSheet } from "./agent-configuration/AgentConfigurationSheet";
type User = RouterOutputs["viewer"]["me"]["get"];
@@ -477,7 +477,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
const verifyEmailCodeMutation = trpc.viewer.workflows.verifyEmailCode.useMutation({
onSuccess: (isVerified) => {
showToast(isVerified ? t("verified_successfully") : t("wrong_code"), "success");
setEmailVerified(true);
setEmailVerified(isVerified);
if (
step &&
form?.formState?.errors?.steps &&
@@ -800,6 +800,15 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
form.setValue(`steps.${step.stepNumber - 1}.agentId`, null);
}
if (
val.value !== WorkflowActions.EMAIL_ATTENDEE &&
val.value !== WorkflowActions.SMS_ATTENDEE
) {
form.setValue(`steps.${step.stepNumber - 1}.autoTranslateEnabled`, false, {
shouldDirty: true,
});
}
form.setValue(`steps.${step.stepNumber - 1}.sendTo`, null);
form.clearErrors(`steps.${step.stepNumber - 1}.sendTo`);
form.setValue(`steps.${step.stepNumber - 1}.action`, val.value, {
@@ -1433,6 +1442,49 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
/>
</div>
)}
{(step.action === WorkflowActions.EMAIL_ATTENDEE ||
step.action === WorkflowActions.SMS_ATTENDEE) && (
<div className="mt-2">
<div className="flex items-center gap-2">
<Controller
name={`steps.${step.stepNumber - 1}.autoTranslateEnabled`}
control={form.control}
render={() => (
<CheckboxField
disabled={props.readOnly || !props.user.organizationId}
defaultChecked={
form.getValues(`steps.${step.stepNumber - 1}.autoTranslateEnabled`) || false
}
description={t("auto_translate_for_attendees")}
descriptionClassName="ml-0"
onChange={(e) =>
form.setValue(
`steps.${step.stepNumber - 1}.autoTranslateEnabled`,
e.target.checked,
{ shouldDirty: true }
)
}
/>
)}
/>
{!props.user.organizationId && (
<Badge variant="gray" size="sm">
{t("upgrade_to_organizations")}
</Badge>
)}
</div>
{props.user.organizationId &&
form.watch(`steps.${step.stepNumber - 1}.autoTranslateEnabled`) && (
<p className="text-subtle ml-6 mt-1 text-xs">
{t("auto_translate_source_language_hint", {
language: new Intl.DisplayNames([i18n.language], { type: "language" }).of(
props.user.locale || "en"
),
})}
</p>
)}
</div>
)}
{!props.readOnly && (
<div className="ml-1 mt-2">
<button type="button" onClick={() => setIsAdditionalInputsDialogOpen(true)}>
@@ -0,0 +1,10 @@
import type { ITranslationService } from "@calcom/features/translation/services/ITranslationService";
import { createContainer } from "../di";
import { moduleLoader as translationServiceModuleLoader } from "../modules/TranslationService";
const translationServiceContainer = createContainer();
export function getTranslationService(): Promise<ITranslationService> {
translationServiceModuleLoader.loadModule(translationServiceContainer);
return translationServiceContainer.get<Promise<ITranslationService>>(translationServiceModuleLoader.token);
}
@@ -0,0 +1,43 @@
import { moduleLoader as workflowStepTranslationRepositoryModuleLoader } from "@calcom/features/ee/workflows/di/WorkflowStepTranslationRepository.module";
import type { WorkflowStepTranslationRepository } from "@calcom/features/ee/workflows/repositories/WorkflowStepTranslationRepository";
import { moduleLoader as eventTypeTranslationRepositoryModuleLoader } from "@calcom/features/eventTypeTranslation/di/EventTypeTranslationRepository.module";
import type { EventTypeTranslationRepository } from "@calcom/features/eventTypeTranslation/repositories/EventTypeTranslationRepository";
import { TRANSLATION_DI_TOKENS } from "@calcom/features/translation/di/tokens";
import { TranslationService } from "@calcom/features/translation/services/TranslationService";
import type { Container } from "@evyweb/ioctopus";
import { createModule, type ModuleLoader } from "../di";
const token: symbol = TRANSLATION_DI_TOKENS.TRANSLATION_SERVICE;
const moduleToken: symbol = TRANSLATION_DI_TOKENS.TRANSLATION_SERVICE_MODULE;
const loadModule = (container: Container) => {
workflowStepTranslationRepositoryModuleLoader.loadModule(container);
eventTypeTranslationRepositoryModuleLoader.loadModule(container);
const workflowStepTranslationRepository = container.get<WorkflowStepTranslationRepository>(
TRANSLATION_DI_TOKENS.WORKFLOW_STEP_TRANSLATION_REPOSITORY
);
const eventTypeTranslationRepository = container.get<EventTypeTranslationRepository>(
TRANSLATION_DI_TOKENS.EVENT_TYPE_TRANSLATION_REPOSITORY
);
const thisModule = createModule();
thisModule.bind(token).toFactory(async () => {
const { LingoDotDevService } = await import("@calcom/lib/server/service/lingoDotDev");
return new TranslationService({
localizeText: (text, sourceLocale, targetLocale) =>
LingoDotDevService.localizeText(text, sourceLocale, targetLocale),
workflowStepTranslationRepository,
eventTypeTranslationRepository,
});
});
container.load(moduleToken, thisModule);
};
export const moduleLoader: ModuleLoader = {
token,
loadModule,
};
export type { TranslationService };
+2
View File
@@ -6,6 +6,7 @@ import { FLAGS_DI_TOKENS } from "@calcom/features/flags/di/tokens";
import { HASHED_LINK_DI_TOKENS } from "@calcom/features/hashedLink/di/tokens";
import { OAUTH_DI_TOKENS } from "@calcom/features/oauth/di/tokens";
import { ORGANIZATION_DI_TOKENS } from "@calcom/features/ee/organizations/di/tokens";
import { TRANSLATION_DI_TOKENS } from "@calcom/features/translation/di/tokens";
import { WATCHLIST_DI_TOKENS } from "./watchlist/Watchlist.tokens";
import { WEBHOOK_TOKENS } from "./webhooks/Webhooks.tokens";
@@ -86,5 +87,6 @@ export const DI_TOKENS = {
...WATCHLIST_DI_TOKENS,
...ACTIVE_USER_BILLING_DI_TOKENS,
...ORGANIZATION_DI_TOKENS,
...TRANSLATION_DI_TOKENS,
...WEBHOOK_TOKENS,
};
@@ -0,0 +1,15 @@
import { createContainer } from "@calcom/features/di/di";
import {
moduleLoader as workflowStepTranslationRepositoryModuleLoader,
type WorkflowStepTranslationRepository,
} from "./WorkflowStepTranslationRepository.module";
const workflowStepTranslationRepositoryContainer = createContainer();
export function getWorkflowStepTranslationRepository(): WorkflowStepTranslationRepository {
workflowStepTranslationRepositoryModuleLoader.loadModule(workflowStepTranslationRepositoryContainer);
return workflowStepTranslationRepositoryContainer.get<WorkflowStepTranslationRepository>(
workflowStepTranslationRepositoryModuleLoader.token
);
}
@@ -0,0 +1,23 @@
import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di";
import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma";
import { TRANSLATION_DI_TOKENS } from "@calcom/features/translation/di/tokens";
import { WorkflowStepTranslationRepository } from "../repositories/WorkflowStepTranslationRepository";
const thisModule = createModule();
const token = TRANSLATION_DI_TOKENS.WORKFLOW_STEP_TRANSLATION_REPOSITORY;
const moduleToken = TRANSLATION_DI_TOKENS.WORKFLOW_STEP_TRANSLATION_REPOSITORY_MODULE;
const loadModule = bindModuleToClassOnToken({
module: thisModule,
moduleToken,
token,
classs: WorkflowStepTranslationRepository,
dep: prismaModuleLoader,
});
export const moduleLoader: ModuleLoader = {
token,
loadModule,
};
export type { WorkflowStepTranslationRepository };
@@ -24,6 +24,8 @@ export const workflowSelect = {
includeCalendarEvent: true,
numberRequired: true,
verifiedAt: true,
autoTranslateEnabled: true,
sourceLocale: true,
},
},
};
@@ -38,6 +38,8 @@ type scheduleEmailReminderArgs = ScheduleReminderArgs & {
hideBranding?: boolean;
includeCalendarEvent?: boolean;
verifiedAt: Date | null;
autoTranslateEnabled?: boolean;
sourceLocale?: string | null;
};
type SendEmailReminderParams = {
@@ -115,6 +117,8 @@ const scheduleEmailReminderForEvt = async (args: scheduleEmailReminderArgs & { e
hideBranding,
includeCalendarEvent,
action,
autoTranslateEnabled,
sourceLocale,
} = args;
const uid = evt.uid as string;
@@ -152,6 +156,9 @@ const scheduleEmailReminderForEvt = async (args: scheduleEmailReminderArgs & { e
template,
includeCalendarEvent,
triggerEvent,
workflowStepId,
autoTranslateEnabled,
sourceLocale,
});
await sendOrScheduleWorkflowEmailWithReminder({
@@ -2,12 +2,12 @@ import { BookingSeatRepository } from "@calcom/features/bookings/repositories/Bo
import type { CreditCheckFn } from "@calcom/features/ee/billing/credit-service";
import {
isAttendeeAction,
isCalAIAction,
isEmailAction,
isSMSAction,
isSMSOrWhatsappAction,
isWhatsappAction,
isCalAIAction,
} from "@calcom/features/ee/workflows/lib/actionHelperFunctions";
import { isEmailAction } 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";
@@ -17,12 +17,9 @@ import { withReporting } from "@calcom/lib/sentryWrapper";
import { getTranslation } from "@calcom/i18n/server";
import { checkSMSRateLimit } from "@calcom/lib/smsLockState";
import { prisma } from "@calcom/prisma";
import { SchedulingType } from "@calcom/prisma/enums";
import { WorkflowActions, WorkflowTriggerEvents } from "@calcom/prisma/enums";
import { type SchedulingType, WorkflowActions, WorkflowTriggerEvents } from "@calcom/prisma/enums";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { FormSubmissionData } from "../types";
import type { BookingInfo } from "../types";
import type { BookingInfo, FormSubmissionData } from "../types";
import type { ScheduleTextReminderAction } from "./smsReminderManager";
export type WorkflowContextData =
@@ -137,6 +134,8 @@ const processWorkflowStep = async (
action: step.action as ScheduleTextReminderAction,
message: step.reminderBody || "",
sender: step.sender,
autoTranslateEnabled: step.autoTranslateEnabled,
sourceLocale: step.sourceLocale,
isVerificationPending: step.numberVerificationPending,
...contextData,
});
@@ -180,6 +179,8 @@ const processWorkflowStep = async (
reminderPhone: sendTo,
action: step.action as ScheduleTextReminderAction,
message: step.reminderBody || "",
autoTranslateEnabled: step.autoTranslateEnabled,
sourceLocale: step.sourceLocale,
isVerificationPending: step.numberVerificationPending,
evt,
});
@@ -4,6 +4,7 @@ import {
getSMSMessageWithVariables,
shouldUseTwilio,
} from "@calcom/ee/workflows/lib/reminders/utils";
import { getTranslationService } from "@calcom/features/di/containers/TranslationService";
import type { CreditCheckFn } from "@calcom/features/ee/billing/credit-service";
import { getSubmitterEmail } from "@calcom/features/tasker/tasks/triggerFormSubmittedNoEvent/formSubmissionValidation";
import { SENDER_ID } from "@calcom/lib/constants";
@@ -13,15 +14,18 @@ import { getTranslation } from "@calcom/i18n/server";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import type { PrismaClient } from "@calcom/prisma";
import prisma from "@calcom/prisma";
import { WorkflowTemplates, WorkflowActions, WorkflowMethods } from "@calcom/prisma/enums";
import { WorkflowTriggerEvents } from "@calcom/prisma/enums";
import {
WorkflowActions,
WorkflowMethods,
WorkflowTemplates,
WorkflowTriggerEvents,
} from "@calcom/prisma/enums";
import { isAttendeeAction } from "../actionHelperFunctions";
import { getSenderId } from "../alphanumericSenderIdSupport";
import { IMMEDIATE_WORKFLOW_TRIGGER_EVENTS } from "../constants";
import { WorkflowOptOutContactRepository } from "../repository/workflowOptOutContact";
import { WorkflowOptOutService } from "../service/workflowOptOutService";
import type { FormSubmissionData, BookingInfo } from "../types";
import type { BookingInfo, FormSubmissionData } from "../types";
import type { ScheduleReminderArgs } from "./emailReminderManager";
import { scheduleSmsOrFallbackEmail, sendSmsOrFallbackEmail } from "./messageDispatcher";
import * as twilio from "./providers/twilioProvider";
@@ -49,6 +53,8 @@ export type ScheduleTextReminderArgs = ScheduleReminderArgs & {
prisma?: PrismaClient;
verifiedAt: Date | null;
creditCheckFn: CreditCheckFn;
autoTranslateEnabled?: boolean;
sourceLocale?: string | null;
};
export type ScheduleTextReminderArgsWithRequiredFields = Omit<
@@ -170,6 +176,24 @@ const scheduleSMSReminderForEvt = async (
let smsMessage = message;
if (
smsMessage &&
args.autoTranslateEnabled &&
action === WorkflowActions.SMS_ATTENDEE &&
workflowStepId
) {
const attendeeLocale = attendeeToBeUsedInSMS.language?.locale || "en";
const translationService = await getTranslationService();
const { translatedBody } = await translationService.getWorkflowStepTranslation(
workflowStepId,
attendeeLocale,
{ includeBody: true }
);
if (translatedBody) {
smsMessage = translatedBody;
}
}
if (smsMessage) {
smsMessage = await getSMSMessageWithVariables(smsMessage, evt, attendeeToBeUsedInSMS, action);
} else if (template === WorkflowTemplates.REMINDER) {
@@ -60,7 +60,9 @@ export const getSMSMessageWithVariables = async (
await bulkShortenLinks([urls.meetingUrl, urls.cancelLink, urls.rescheduleLink]);
const timeZone =
action === WorkflowActions.SMS_ATTENDEE ? attendeeToBeUsedInSMS.timeZone : evt.organizer.timeZone;
action === WorkflowActions.SMS_ATTENDEE || action === WorkflowActions.WHATSAPP_ATTENDEE
? attendeeToBeUsedInSMS.timeZone
: evt.organizer.timeZone;
const variables: VariablesType = {
eventName: evt.title,
@@ -86,7 +88,7 @@ export const getSMSMessageWithVariables = async (
};
const locale =
action === WorkflowActions.SMS_ATTENDEE
action === WorkflowActions.SMS_ATTENDEE || action === WorkflowActions.WHATSAPP_ATTENDEE
? attendeeToBeUsedInSMS.language?.locale
: evt.organizer.language.locale;
@@ -3,10 +3,10 @@ import logger from "@calcom/lib/logger";
import { getTranslation } from "@calcom/i18n/server";
import prisma from "@calcom/prisma";
import {
WorkflowTriggerEvents,
WorkflowTemplates,
WorkflowActions,
WorkflowMethods,
WorkflowTemplates,
WorkflowTriggerEvents,
} from "@calcom/prisma/enums";
import { isAttendeeAction } from "../actionHelperFunctions";
@@ -96,7 +96,7 @@ export const scheduleWhatsappReminder = async (args: ScheduleTextReminderArgs &
const locale = evt.organizer.language.locale;
const timeFormat = evt.organizer.timeFormat;
const contentSid = getContentSidForTemplate(template);
let contentSid: string | undefined = getContentSidForTemplate(template);
const contentVariables = getContentVariablesForTemplate({
name,
attendeeName,
@@ -182,6 +182,12 @@ export const scheduleWhatsappReminder = async (args: ScheduleTextReminderArgs &
) || message;
}
// Note: WhatsApp translation is not currently supported because:
// 1. WhatsApp Business API requires pre-approved Message Templates (contentSid) for business-initiated messages
// 2. The Twilio provider only sends contentSid/contentVariables for WhatsApp, ignoring the body parameter
// 3. Sending free-form text without a template will be rejected by WhatsApp outside the 24-hour service window
// To enable WhatsApp translation, a generic template with a single variable for the entire body would be needed.
// Allows debugging generated whatsapp content without waiting for twilio to send whatsapp messages
log.debug(`Sending Whatsapp for trigger ${triggerEvent}`, textMessage);
if (textMessage.length > 0 && reminderPhone && isNumberVerified) {
@@ -120,6 +120,8 @@ export async function scheduleBookingReminders(
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({
@@ -176,6 +178,8 @@ export async function scheduleBookingReminders(
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) {
@@ -195,6 +199,8 @@ export async function scheduleBookingReminders(
userId: userId,
teamId: teamId,
verifiedAt: step?.verifiedAt ?? null,
autoTranslateEnabled: step.autoTranslateEnabled,
sourceLocale: step.sourceLocale,
creditCheckFn: creditService.hasAvailableCredits.bind(creditService),
});
}
@@ -40,6 +40,8 @@ export const formSchema = z.object({
.nullable(),
senderName: z.string().nullish(),
agentId: z.string().nullish(),
autoTranslateEnabled: z.boolean().optional(),
sourceLocale: z.string().nullish(),
})
.array(),
selectAll: z.boolean(),
@@ -1,7 +1,6 @@
import { describe, expect, vi, beforeEach, test } from "vitest";
import type { BookingSeatRepository } from "@calcom/features/bookings/repositories/BookingSeatRepository";
import type { WorkflowReminderRepository } from "@calcom/features/ee/workflows/repositories/WorkflowReminderRepository";
import { TimeFormat } from "@calcom/lib/timeFormat";
import {
SchedulingType,
TimeUnit,
@@ -9,9 +8,8 @@ import {
WorkflowTemplates,
WorkflowTriggerEvents,
} from "@calcom/prisma/enums";
import { TimeFormat } from "@calcom/lib/timeFormat";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { EmailWorkflowService } from "./EmailWorkflowService";
vi.mock("@calcom/emails/workflow-email-service", () => ({
@@ -35,6 +33,14 @@ vi.mock("@calcom/emails/lib/generateIcsString", () => ({
default: vi.fn().mockReturnValue("mock-ics-content"),
}));
const mockTranslationService = vi.hoisted(() => ({
getWorkflowStepTranslation: vi.fn(),
}));
vi.mock("@calcom/features/di/containers/TranslationService", () => ({
getTranslationService: vi.fn().mockResolvedValue(mockTranslationService),
}));
vi.mock("@calcom/lib/constants", async () => {
const actual = await vi.importActual<typeof import("@calcom/lib/constants")>("@calcom/lib/constants");
return {
@@ -62,6 +68,7 @@ describe("EmailWorkflowService", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(mockTranslationService.getWorkflowStepTranslation).mockReset();
emailWorkflowService = new EmailWorkflowService(
mockWorkflowReminderRepository as WorkflowReminderRepository,
mockBookingSeatRepository as BookingSeatRepository
@@ -598,6 +605,129 @@ describe("EmailWorkflowService", () => {
});
});
describe("generateEmailPayloadForEvtWorkflow - Auto Translation", () => {
const mockBookingInfo = {
uid: "booking-123",
bookerUrl: "https://cal.com",
title: "Test Meeting",
startTime: "2024-12-01T10:00:00Z",
endTime: "2024-12-01T11:00:00Z",
organizer: {
name: "Organizer Name",
email: "organizer@example.com",
timeZone: "UTC",
language: { locale: "en" },
timeFormat: "h:mma",
},
attendees: [
{
name: "Spanish Attendee",
email: "attendee@example.com",
timeZone: "Europe/Madrid",
language: { locale: "es" },
},
],
};
beforeEach(() => {
vi.mocked(mockTranslationService.getWorkflowStepTranslation).mockReset();
});
test("should use translated content when autoTranslateEnabled is true and translation exists", async () => {
vi.mocked(mockTranslationService.getWorkflowStepTranslation).mockResolvedValue({
translatedBody: "Cuerpo traducido",
translatedSubject: "Asunto traducido",
});
const result = await emailWorkflowService.generateEmailPayloadForEvtWorkflow({
evt: mockBookingInfo,
sendTo: ["attendee@example.com"],
hideBranding: false,
emailSubject: "Original Subject",
emailBody: "Original Body",
sender: "Cal.com",
action: WorkflowActions.EMAIL_ATTENDEE,
template: WorkflowTemplates.CUSTOM,
includeCalendarEvent: false,
triggerEvent: WorkflowTriggerEvents.BEFORE_EVENT,
workflowStepId: 123,
autoTranslateEnabled: true,
sourceLocale: "en",
});
expect(mockTranslationService.getWorkflowStepTranslation).toHaveBeenCalledTimes(1);
expect(result.subject).toBe("Asunto traducido");
expect(result.html).toContain("Cuerpo traducido");
});
test("should use original content when autoTranslateEnabled is false", async () => {
const result = await emailWorkflowService.generateEmailPayloadForEvtWorkflow({
evt: mockBookingInfo,
sendTo: ["attendee@example.com"],
hideBranding: false,
emailSubject: "Original Subject",
emailBody: "Original Body",
sender: "Cal.com",
action: WorkflowActions.EMAIL_ATTENDEE,
template: WorkflowTemplates.CUSTOM,
includeCalendarEvent: false,
triggerEvent: WorkflowTriggerEvents.BEFORE_EVENT,
workflowStepId: 123,
autoTranslateEnabled: false,
});
expect(mockTranslationService.getWorkflowStepTranslation).not.toHaveBeenCalled();
expect(result.subject).toBe("Original Subject");
});
test("should fallback to original content when translation not found", async () => {
vi.mocked(mockTranslationService.getWorkflowStepTranslation).mockResolvedValue({
translatedBody: null,
translatedSubject: null,
});
const result = await emailWorkflowService.generateEmailPayloadForEvtWorkflow({
evt: mockBookingInfo,
sendTo: ["attendee@example.com"],
hideBranding: false,
emailSubject: "Original Subject",
emailBody: "Original Body",
sender: "Cal.com",
action: WorkflowActions.EMAIL_ATTENDEE,
template: WorkflowTemplates.CUSTOM,
includeCalendarEvent: false,
triggerEvent: WorkflowTriggerEvents.BEFORE_EVENT,
workflowStepId: 123,
autoTranslateEnabled: true,
sourceLocale: "en",
});
expect(mockTranslationService.getWorkflowStepTranslation).toHaveBeenCalledTimes(1);
expect(result.subject).toBe("Original Subject");
});
test("should not translate for non-attendee actions", async () => {
const result = await emailWorkflowService.generateEmailPayloadForEvtWorkflow({
evt: mockBookingInfo,
sendTo: ["organizer@example.com"],
hideBranding: false,
emailSubject: "Original Subject",
emailBody: "Original Body",
sender: "Cal.com",
action: WorkflowActions.EMAIL_HOST,
template: WorkflowTemplates.CUSTOM,
includeCalendarEvent: false,
triggerEvent: WorkflowTriggerEvents.BEFORE_EVENT,
workflowStepId: 123,
autoTranslateEnabled: true,
sourceLocale: "en",
});
expect(mockTranslationService.getWorkflowStepTranslation).not.toHaveBeenCalled();
expect(result.subject).toBe("Original Subject");
});
});
describe("generateEmailPayloadForEvtWorkflow - Cal Video meeting URL", () => {
const mockBookingInfoWithCalVideo = {
uid: "booking-cal-video-123",
@@ -1,9 +1,8 @@
import type { EventStatus } from "ics";
import dayjs from "@calcom/dayjs";
import generateIcsString from "@calcom/emails/lib/generateIcsString";
import { sendCustomWorkflowEmail } from "@calcom/emails/workflow-email-service";
import type { BookingSeatRepository } from "@calcom/features/bookings/repositories/BookingSeatRepository";
import { getTranslationService } from "@calcom/features/di/containers/TranslationService";
import type { Workflow, WorkflowStep } from "@calcom/features/ee/workflows/lib/types";
import { preprocessNameFieldDataWithVariant } from "@calcom/features/form-builder/utils";
import { getHideBranding } from "@calcom/features/profile/lib/hideBranding";
@@ -12,19 +11,23 @@ import { UserRepository } from "@calcom/features/users/repositories/UserReposito
import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser";
import { SENDER_NAME, WEBSITE_URL } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { TimeFormat } from "@calcom/lib/timeFormat";
import { getTranslation } from "@calcom/i18n/server";
import { TimeFormat } from "@calcom/lib/timeFormat";
import { prisma } from "@calcom/prisma";
import { WorkflowActions, WorkflowTemplates } from "@calcom/prisma/enums";
import { SchedulingType, WorkflowTriggerEvents } from "@calcom/prisma/enums";
import {
SchedulingType,
WorkflowActions,
WorkflowTemplates,
WorkflowTriggerEvents,
} from "@calcom/prisma/enums";
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
import { CalendarEvent } from "@calcom/types/Calendar";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { EventStatus } from "ics";
import type { WorkflowReminderRepository } from "../../repositories/WorkflowReminderRepository";
import {
isEmailAction,
getTemplateBodyForAction,
getTemplateSubjectForAction,
isEmailAction,
} from "../actionHelperFunctions";
import { detectMatchedTemplate } from "../detectMatchedTemplate";
import { getWorkflowRecipientEmail } from "../getWorkflowReminders";
@@ -35,11 +38,11 @@ import customTemplate, {
import emailRatingTemplate from "../reminders/templates/emailRatingTemplate";
import emailReminderTemplate from "../reminders/templates/emailReminderTemplate";
import type {
FormSubmissionData,
WorkflowContextData,
AttendeeInBookingInfo,
BookingInfo,
FormSubmissionData,
ScheduleEmailReminderAction,
WorkflowContextData,
} from "../types";
import { WorkflowService } from "./WorkflowService";
@@ -239,6 +242,9 @@ export class EmailWorkflowService {
includeCalendarEvent: workflowStep.includeCalendarEvent,
...contextData,
verifiedAt: workflowStep.verifiedAt,
workflowStepId: workflowStep.id,
autoTranslateEnabled: workflowStep.autoTranslateEnabled,
sourceLocale: workflowStep.sourceLocale,
} as const;
}
@@ -275,6 +281,9 @@ export class EmailWorkflowService {
template,
includeCalendarEvent,
triggerEvent,
workflowStepId,
autoTranslateEnabled,
sourceLocale,
}: {
evt: BookingInfo;
sendTo: string[];
@@ -287,6 +296,9 @@ export class EmailWorkflowService {
template?: WorkflowTemplates;
includeCalendarEvent?: boolean;
triggerEvent: WorkflowTriggerEvents;
workflowStepId?: number;
autoTranslateEnabled?: boolean;
sourceLocale?: string | null;
}) {
const log = logger.getSubLogger({
prefix: [`[generateEmailPayloadForEvtWorkflow]: bookingUid: ${evt?.uid}`],
@@ -509,10 +521,33 @@ export class EmailWorkflowService {
eventEndTimeInAttendeeTimezone: dayjs(endTime).tz(attendeeToBeUsedInMail.timeZone),
};
const emailSubjectTemplate = customTemplate(emailSubject, variables, locale, evt.organizer.timeFormat);
let translatedEmailBody = emailBody;
let translatedEmailSubject = emailSubject;
if (autoTranslateEnabled && isEmailAttendeeAction && workflowStepId) {
const translationService = await getTranslationService();
const { translatedBody, translatedSubject } = await translationService.getWorkflowStepTranslation(
workflowStepId,
locale,
{ includeBody: true, includeSubject: true }
);
if (translatedBody) {
translatedEmailBody = translatedBody;
}
if (translatedSubject) {
translatedEmailSubject = translatedSubject;
}
}
const emailSubjectTemplate = customTemplate(
translatedEmailSubject,
variables,
locale,
evt.organizer.timeFormat
);
emailContent.emailSubject = emailSubjectTemplate.text;
emailContent.emailBody = customTemplate(
emailBody,
translatedEmailBody,
variables,
locale,
evt.organizer.timeFormat,
@@ -152,6 +152,8 @@ async function createWorkflowRemindersAndTasksForWorkflow(workflowName: string)
numberVerificationPending: true,
includeCalendarEvent: true,
verifiedAt: true,
autoTranslateEnabled: true,
sourceLocale: true,
},
},
},
@@ -39,6 +39,8 @@ export type WorkflowStep = {
numberVerificationPending: boolean;
numberRequired: boolean | null;
verifiedAt?: Date | null;
autoTranslateEnabled?: boolean;
sourceLocale?: string | null;
};
export const ZWorkflow: z.ZodType<Workflow> = z.object({
@@ -125,6 +125,8 @@ export class WorkflowReminderRepository {
sender: true,
numberVerificationPending: true,
numberRequired: true,
autoTranslateEnabled: true,
sourceLocale: true,
workflow: {
select: {
id: true,
@@ -3,6 +3,17 @@ import type { Prisma, PrismaClient } from "@calcom/prisma/client";
export class WorkflowStepRepository {
constructor(private prismaClient: PrismaClient) {}
async findTranslationDataById(id: number) {
return await this.prismaClient.workflowStep.findUnique({
where: { id },
select: {
reminderBody: true,
emailSubject: true,
sourceLocale: true,
},
});
}
async deleteById(id: number) {
return await this.prismaClient.workflowStep.delete({
where: { id },
@@ -0,0 +1,117 @@
import prismaMock from "@calcom/testing/lib/__mocks__/prismaMock";
import { WorkflowStepAutoTranslatedField } from "@calcom/prisma/enums";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { WorkflowStepTranslationRepository } from "./WorkflowStepTranslationRepository";
describe("WorkflowStepTranslationRepository", () => {
const repository = new WorkflowStepTranslationRepository(prismaMock);
beforeEach(() => {
vi.resetAllMocks();
});
describe("upsertManyBodyTranslations", () => {
it("should upsert body translations with correct field", async () => {
prismaMock.workflowStepTranslation.upsert.mockResolvedValue({} as never);
const translations = [
{ workflowStepId: 1, sourceLocale: "en", targetLocale: "es", translatedText: "Hola" },
{ workflowStepId: 1, sourceLocale: "en", targetLocale: "fr", translatedText: "Bonjour" },
];
await repository.upsertManyBodyTranslations(translations);
expect(prismaMock.workflowStepTranslation.upsert).toHaveBeenCalledTimes(2);
expect(prismaMock.workflowStepTranslation.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: {
workflowStepId_field_targetLocale: {
workflowStepId: 1,
field: WorkflowStepAutoTranslatedField.REMINDER_BODY,
targetLocale: "es",
},
},
create: expect.objectContaining({
field: WorkflowStepAutoTranslatedField.REMINDER_BODY,
}),
})
);
});
});
describe("upsertManySubjectTranslations", () => {
it("should upsert subject translations with correct field", async () => {
prismaMock.workflowStepTranslation.upsert.mockResolvedValue({} as never);
const translations = [
{ workflowStepId: 1, sourceLocale: "en", targetLocale: "de", translatedText: "Betreff" },
];
await repository.upsertManySubjectTranslations(translations);
expect(prismaMock.workflowStepTranslation.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: {
workflowStepId_field_targetLocale: {
workflowStepId: 1,
field: WorkflowStepAutoTranslatedField.EMAIL_SUBJECT,
targetLocale: "de",
},
},
create: expect.objectContaining({
field: WorkflowStepAutoTranslatedField.EMAIL_SUBJECT,
}),
})
);
});
});
describe("findByLocale", () => {
it("should find translation by locale", async () => {
const mockTranslation = {
uid: "123",
workflowStepId: 1,
field: WorkflowStepAutoTranslatedField.REMINDER_BODY,
sourceLocale: "en",
targetLocale: "es",
translatedText: "Hola",
createdAt: new Date(),
updatedAt: new Date(),
};
prismaMock.workflowStepTranslation.findUnique.mockResolvedValue(mockTranslation);
const result = await repository.findByLocale(1, WorkflowStepAutoTranslatedField.REMINDER_BODY, "es");
expect(result).toEqual(mockTranslation);
expect(prismaMock.workflowStepTranslation.findUnique).toHaveBeenCalledWith({
where: {
workflowStepId_field_targetLocale: {
workflowStepId: 1,
field: WorkflowStepAutoTranslatedField.REMINDER_BODY,
targetLocale: "es",
},
},
});
});
it("should return null when translation not found", async () => {
prismaMock.workflowStepTranslation.findUnique.mockResolvedValue(null);
const result = await repository.findByLocale(1, WorkflowStepAutoTranslatedField.REMINDER_BODY, "xx");
expect(result).toBeNull();
});
});
describe("deleteByWorkflowStepId", () => {
it("should delete all translations for a workflow step", async () => {
prismaMock.workflowStepTranslation.deleteMany.mockResolvedValue({ count: 18 });
await repository.deleteByWorkflowStepId(1);
expect(prismaMock.workflowStepTranslation.deleteMany).toHaveBeenCalledWith({
where: { workflowStepId: 1 },
});
});
});
});
@@ -0,0 +1,79 @@
import type { PrismaClient, WorkflowStepTranslation } from "@calcom/prisma/client";
import { WorkflowStepAutoTranslatedField } from "@calcom/prisma/enums";
export type CreateWorkflowStepTranslation = Omit<
WorkflowStepTranslation,
"uid" | "createdAt" | "updatedAt" | "workflowStep" | "field"
>;
export class WorkflowStepTranslationRepository {
constructor(private readonly prismaClient: PrismaClient) {}
async upsertManyBodyTranslations(translations: Array<CreateWorkflowStepTranslation>) {
return await Promise.all(
translations.map((translation) => {
return this.prismaClient.workflowStepTranslation.upsert({
where: {
workflowStepId_field_targetLocale: {
workflowStepId: translation.workflowStepId,
field: WorkflowStepAutoTranslatedField.REMINDER_BODY,
targetLocale: translation.targetLocale,
},
},
update: {
translatedText: translation.translatedText,
},
create: {
...translation,
field: WorkflowStepAutoTranslatedField.REMINDER_BODY,
},
});
})
);
}
async upsertManySubjectTranslations(translations: Array<CreateWorkflowStepTranslation>) {
return await Promise.all(
translations.map((translation) => {
return this.prismaClient.workflowStepTranslation.upsert({
where: {
workflowStepId_field_targetLocale: {
workflowStepId: translation.workflowStepId,
field: WorkflowStepAutoTranslatedField.EMAIL_SUBJECT,
targetLocale: translation.targetLocale,
},
},
update: {
translatedText: translation.translatedText,
},
create: {
...translation,
field: WorkflowStepAutoTranslatedField.EMAIL_SUBJECT,
},
});
})
);
}
async findByLocale(
workflowStepId: number,
field: WorkflowStepAutoTranslatedField,
targetLocale: string
): Promise<WorkflowStepTranslation | null> {
return this.prismaClient.workflowStepTranslation.findUnique({
where: {
workflowStepId_field_targetLocale: {
workflowStepId,
field,
targetLocale,
},
},
});
}
async deleteByWorkflowStepId(workflowStepId: number) {
return this.prismaClient.workflowStepTranslation.deleteMany({
where: { workflowStepId },
});
}
}
@@ -0,0 +1,15 @@
import { createContainer } from "@calcom/features/di/di";
import {
moduleLoader as eventTypeTranslationRepositoryModuleLoader,
type EventTypeTranslationRepository,
} from "./EventTypeTranslationRepository.module";
const eventTypeTranslationRepositoryContainer = createContainer();
export function getEventTypeTranslationRepository(): EventTypeTranslationRepository {
eventTypeTranslationRepositoryModuleLoader.loadModule(eventTypeTranslationRepositoryContainer);
return eventTypeTranslationRepositoryContainer.get<EventTypeTranslationRepository>(
eventTypeTranslationRepositoryModuleLoader.token
);
}
@@ -0,0 +1,23 @@
import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di";
import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma";
import { TRANSLATION_DI_TOKENS } from "@calcom/features/translation/di/tokens";
import { EventTypeTranslationRepository } from "../repositories/EventTypeTranslationRepository";
const thisModule = createModule();
const token = TRANSLATION_DI_TOKENS.EVENT_TYPE_TRANSLATION_REPOSITORY;
const moduleToken = TRANSLATION_DI_TOKENS.EVENT_TYPE_TRANSLATION_REPOSITORY_MODULE;
const loadModule = bindModuleToClassOnToken({
module: thisModule,
moduleToken,
token,
classs: EventTypeTranslationRepository,
dep: prismaModuleLoader,
});
export const moduleLoader: ModuleLoader = {
token,
loadModule,
};
export type { EventTypeTranslationRepository };
@@ -0,0 +1,140 @@
import prismaMock from "@calcom/testing/lib/__mocks__/prismaMock";
import { EventTypeAutoTranslatedField } from "@calcom/prisma/enums";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { EventTypeTranslationRepository } from "./EventTypeTranslationRepository";
describe("EventTypeTranslationRepository", () => {
const repository = new EventTypeTranslationRepository(prismaMock);
beforeEach(() => {
vi.resetAllMocks();
});
describe("upsertManyTitleTranslations", () => {
it("should upsert title translations with correct field", async () => {
prismaMock.eventTypeTranslation.upsert.mockResolvedValue({} as never);
const translations = [
{ eventTypeId: 1, sourceLocale: "en", targetLocale: "es", translatedText: "Reunión", userId: 1 },
{ eventTypeId: 1, sourceLocale: "en", targetLocale: "fr", translatedText: "Réunion", userId: 1 },
];
await repository.upsertManyTitleTranslations(translations);
expect(prismaMock.eventTypeTranslation.upsert).toHaveBeenCalledTimes(2);
expect(prismaMock.eventTypeTranslation.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: {
eventTypeId_field_targetLocale: {
eventTypeId: 1,
field: EventTypeAutoTranslatedField.TITLE,
targetLocale: "es",
},
},
create: expect.objectContaining({
field: EventTypeAutoTranslatedField.TITLE,
createdBy: 1,
}),
update: expect.objectContaining({
updatedBy: 1,
}),
})
);
});
});
describe("upsertManyDescriptionTranslations", () => {
it("should upsert description translations with correct field", async () => {
prismaMock.eventTypeTranslation.upsert.mockResolvedValue({} as never);
const translations = [
{ eventTypeId: 1, sourceLocale: "en", targetLocale: "de", translatedText: "Beschreibung", userId: 1 },
];
await repository.upsertManyDescriptionTranslations(translations);
expect(prismaMock.eventTypeTranslation.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: {
eventTypeId_field_targetLocale: {
eventTypeId: 1,
field: EventTypeAutoTranslatedField.DESCRIPTION,
targetLocale: "de",
},
},
create: expect.objectContaining({
field: EventTypeAutoTranslatedField.DESCRIPTION,
}),
})
);
});
});
describe("findByLocale", () => {
it("should find translation by locale for title field", async () => {
const mockTranslation = {
uid: "123",
eventTypeId: 1,
field: EventTypeAutoTranslatedField.TITLE,
sourceLocale: "en",
targetLocale: "es",
translatedText: "Reunión",
createdAt: new Date(),
updatedAt: new Date(),
createdBy: 1,
updatedBy: null,
};
prismaMock.eventTypeTranslation.findUnique.mockResolvedValue(mockTranslation);
const result = await repository.findByLocale(1, EventTypeAutoTranslatedField.TITLE, "es");
expect(result).toEqual(mockTranslation);
expect(prismaMock.eventTypeTranslation.findUnique).toHaveBeenCalledWith({
where: {
eventTypeId_field_targetLocale: {
eventTypeId: 1,
field: EventTypeAutoTranslatedField.TITLE,
targetLocale: "es",
},
},
});
});
it("should find translation by locale for description field", async () => {
const mockTranslation = {
uid: "456",
eventTypeId: 1,
field: EventTypeAutoTranslatedField.DESCRIPTION,
sourceLocale: "en",
targetLocale: "fr",
translatedText: "Description traduite",
createdAt: new Date(),
updatedAt: new Date(),
createdBy: 1,
updatedBy: null,
};
prismaMock.eventTypeTranslation.findUnique.mockResolvedValue(mockTranslation);
const result = await repository.findByLocale(1, EventTypeAutoTranslatedField.DESCRIPTION, "fr");
expect(result).toEqual(mockTranslation);
expect(prismaMock.eventTypeTranslation.findUnique).toHaveBeenCalledWith({
where: {
eventTypeId_field_targetLocale: {
eventTypeId: 1,
field: EventTypeAutoTranslatedField.DESCRIPTION,
targetLocale: "fr",
},
},
});
});
it("should return null when translation not found", async () => {
prismaMock.eventTypeTranslation.findUnique.mockResolvedValue(null);
const result = await repository.findByLocale(1, EventTypeAutoTranslatedField.TITLE, "xx");
expect(result).toBeNull();
});
});
});
@@ -1,5 +1,4 @@
import { prisma } from "@calcom/prisma";
import type { EventTypeTranslation } from "@calcom/prisma/client";
import type { EventTypeTranslation, PrismaClient } from "@calcom/prisma/client";
import { EventTypeAutoTranslatedField } from "@calcom/prisma/enums";
export type CreateEventTypeTranslation = Omit<
@@ -16,10 +15,12 @@ export type CreateEventTypeTranslation = Omit<
> & { userId: number };
export class EventTypeTranslationRepository {
static async upsertManyTitleTranslations(translations: Array<CreateEventTypeTranslation>) {
constructor(private readonly prismaClient: PrismaClient) {}
async upsertManyTitleTranslations(translations: Array<CreateEventTypeTranslation>) {
return await Promise.all(
translations.map(({ userId, ...translation }) => {
return prisma.eventTypeTranslation.upsert({
return this.prismaClient.eventTypeTranslation.upsert({
where: {
eventTypeId_field_targetLocale: {
eventTypeId: translation.eventTypeId,
@@ -41,10 +42,10 @@ export class EventTypeTranslationRepository {
);
}
static async upsertManyDescriptionTranslations(translations: Array<CreateEventTypeTranslation>) {
async upsertManyDescriptionTranslations(translations: Array<CreateEventTypeTranslation>) {
return await Promise.all(
translations.map(({ userId, ...translation }) => {
return prisma.eventTypeTranslation.upsert({
return this.prismaClient.eventTypeTranslation.upsert({
where: {
eventTypeId_field_targetLocale: {
eventTypeId: translation.eventTypeId,
@@ -65,4 +66,16 @@ export class EventTypeTranslationRepository {
})
);
}
async findByLocale(eventTypeId: number, field: EventTypeAutoTranslatedField, targetLocale: string) {
return this.prismaClient.eventTypeTranslation.findUnique({
where: {
eventTypeId_field_targetLocale: {
eventTypeId,
field,
targetLocale,
},
},
});
}
}
+3
View File
@@ -21,6 +21,9 @@ type TaskPayloads = {
translateEventTypeData: z.infer<
typeof import("./tasks/translateEventTypeData").ZTranslateEventDataPayloadSchema
>;
translateWorkflowStepData: z.infer<
typeof import("./tasks/translateWorkflowStepData").ZTranslateWorkflowStepDataPayloadSchema
>;
createCRMEvent: z.infer<typeof import("./tasks/crm/schema").createCRMEventSchema>;
sendWorkflowEmails: z.infer<typeof import("./tasks/sendWorkflowEmails").ZSendWorkflowEmailsSchema>;
scanWorkflowBody: z.infer<typeof import("./tasks/scanWorkflowBody").scanWorkflowBodySchema>;
+2
View File
@@ -23,6 +23,8 @@ const tasks: Record<TaskTypes, () => Promise<TaskHandler>> = {
sendSms: () => Promise.resolve(() => Promise.reject(new Error("Not implemented"))),
translateEventTypeData: () =>
import("./translateEventTypeData").then((module) => module.translateEventTypeData),
translateWorkflowStepData: () =>
import("./translateWorkflowStepData").then((module) => module.translateWorkflowStepData),
createCRMEvent: () => import("./crm/createCRMEvent").then((module) => module.createCRMEvent),
sendWorkflowEmails: () => import("./sendWorkflowEmails").then((module) => module.sendWorkflowEmails),
scanWorkflowBody: () => import("./scanWorkflowBody").then((module) => module.scanWorkflowBody),
@@ -1,9 +1,8 @@
import { z } from "zod";
import { EventTypeTranslationRepository } from "@calcom/features/eventTypeTranslation/repositories/EventTypeTranslationRepository";
import { locales as i18nLocales } from "@calcom/lib/i18n";
import { getTranslationService } from "@calcom/features/di/containers/TranslationService";
import { getEventTypeTranslationRepository } from "@calcom/features/eventTypeTranslation/di/EventTypeTranslationRepository.container";
import logger from "@calcom/lib/logger";
import { EventTypeAutoTranslatedField } from "@calcom/prisma/enums";
import { z } from "zod";
export const ZTranslateEventDataPayloadSchema = z.object({
eventTypeId: z.number(),
@@ -13,28 +12,6 @@ export const ZTranslateEventDataPayloadSchema = z.object({
userLocale: z.string(),
});
const SUPPORTED_LOCALES = [
"en", // English
"es", // Spanish
"de", // German
"pt", // Portuguese
"pt-BR", // Portuguese Brazilian
"fr", // French
"it", // Italian
"ar", // Arabic
"ru", // Russian
"zh-CN", // Simplified Chinese
"nl", // Dutch
"zh-TW", // Traditional Chinese
"ko", // Korean
"ja", // Japanese
"sv", // Swedish
"da", // Danish
"is", // Icelandic
"lt", // Lithuanian
"nb", // Norwegian Bokmål
] as const;
async function processTranslations({
text,
userLocale,
@@ -43,29 +20,17 @@ async function processTranslations({
field,
}: {
text: string;
userLocale: string;
eventTypeId: number;
userId: number;
field: EventTypeAutoTranslatedField;
} & z.infer<typeof ZTranslateEventDataPayloadSchema>) {
const { LingoDotDevService } = await import("@calcom/lib/server/service/lingoDotDev");
}): Promise<void> {
try {
const targetLocales = SUPPORTED_LOCALES.filter(
(locale) => locale !== userLocale && i18nLocales.includes(locale)
);
const translationService = await getTranslationService();
const result = await translationService.translateText({ text, sourceLocale: userLocale });
const translations = await Promise.all(
targetLocales.map((targetLocale) => LingoDotDevService.localizeText(text, userLocale, targetLocale))
);
// Filter out null translations and their corresponding locales
const validTranslations = translations
.filter((trans): trans is string => trans !== null)
.map((trans, index) => ({
translatedText: trans,
targetLocale: targetLocales[index],
}));
if (validTranslations.length > 0) {
const translationData = validTranslations.map(({ translatedText, targetLocale }) => ({
if (result.translations.length > 0) {
const translationData = result.translations.map(({ translatedText, targetLocale }) => ({
eventTypeId,
sourceLocale: userLocale,
targetLocale,
@@ -73,12 +38,16 @@ async function processTranslations({
userId,
}));
const upsertMany =
field === EventTypeAutoTranslatedField.DESCRIPTION
? EventTypeTranslationRepository.upsertManyDescriptionTranslations
: EventTypeTranslationRepository.upsertManyTitleTranslations;
const eventTypeTranslationRepository = getEventTypeTranslationRepository();
if (field === EventTypeAutoTranslatedField.DESCRIPTION) {
await eventTypeTranslationRepository.upsertManyDescriptionTranslations(translationData);
} else {
await eventTypeTranslationRepository.upsertManyTitleTranslations(translationData);
}
}
await upsertMany(translationData);
if (result.failedLocales.length > 0) {
logger.warn(`Failed to translate event type ${field} to locales: ${result.failedLocales.join(", ")}`);
}
} catch (error) {
logger.error(`Failed to process ${field} translations:`, error);
@@ -0,0 +1,324 @@
import type { ITranslationService } from "@calcom/features/translation/services/ITranslationService";
import { beforeEach, describe, expect, it, vi } from "vitest";
import logger from "@calcom/lib/logger";
const mockPrisma = vi.hoisted(() => ({
workflowStep: {
findUnique: vi.fn(),
},
}));
const mockTranslationService: ITranslationService = {
translateText: vi.fn(),
getTargetLocales: vi.fn(),
getWorkflowStepTranslation: vi.fn(),
getEventTypeTranslation: vi.fn(),
};
const mockWorkflowStepTranslationRepository = {
upsertManyBodyTranslations: vi.fn(),
upsertManySubjectTranslations: vi.fn(),
findByLocale: vi.fn(),
deleteByWorkflowStepId: vi.fn(),
};
vi.mock("@calcom/features/di/containers/TranslationService", () => ({
getTranslationService: vi.fn(() => Promise.resolve(mockTranslationService)),
}));
vi.mock("@calcom/features/ee/workflows/di/WorkflowStepTranslationRepository.container", () => ({
getWorkflowStepTranslationRepository: vi.fn(() => mockWorkflowStepTranslationRepository),
}));
const mockLogger = vi.hoisted(() => ({
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
}));
vi.mock("@calcom/lib/logger", () => ({
default: mockLogger,
}));
vi.mock("@calcom/prisma", () => ({
__esModule: true,
default: mockPrisma,
}));
import { translateWorkflowStepData } from "./translateWorkflowStepData";
describe("translateWorkflowStepData", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(mockPrisma.workflowStep.findUnique).mockResolvedValue({
reminderBody: "Hello",
emailSubject: null,
sourceLocale: "en",
});
});
it("should translate reminderBody to all supported locales", async () => {
vi.mocked(mockPrisma.workflowStep.findUnique).mockResolvedValue({
reminderBody: "Hello {ATTENDEE_NAME}",
emailSubject: null,
sourceLocale: "en",
});
vi.mocked(mockTranslationService.translateText).mockResolvedValue({
translations: [
{ translatedText: "Translated text", targetLocale: "es" },
{ translatedText: "Translated text", targetLocale: "de" },
],
failedLocales: [],
});
const payload = JSON.stringify({
workflowStepId: 1,
reminderBody: "Hello {ATTENDEE_NAME}",
emailSubject: null,
sourceLocale: "en",
});
await translateWorkflowStepData(payload);
expect(mockTranslationService.translateText).toHaveBeenCalledWith({
text: "Hello {ATTENDEE_NAME}",
sourceLocale: "en",
});
expect(mockWorkflowStepTranslationRepository.upsertManyBodyTranslations).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
workflowStepId: 1,
sourceLocale: "en",
translatedText: "Translated text",
}),
])
);
});
it("should translate emailSubject when provided", async () => {
vi.mocked(mockPrisma.workflowStep.findUnique).mockResolvedValue({
reminderBody: null,
emailSubject: "Booking Reminder",
sourceLocale: "en",
});
vi.mocked(mockTranslationService.translateText).mockResolvedValue({
translations: [{ translatedText: "Translated subject", targetLocale: "es" }],
failedLocales: [],
});
const payload = JSON.stringify({
workflowStepId: 1,
reminderBody: null,
emailSubject: "Booking Reminder",
sourceLocale: "en",
});
await translateWorkflowStepData(payload);
expect(mockWorkflowStepTranslationRepository.upsertManySubjectTranslations).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
workflowStepId: 1,
sourceLocale: "en",
translatedText: "Translated subject",
}),
])
);
});
it("should translate both body and subject when provided", async () => {
vi.mocked(mockPrisma.workflowStep.findUnique).mockResolvedValue({
reminderBody: "Body text",
emailSubject: "Subject text",
sourceLocale: "en",
});
vi.mocked(mockTranslationService.translateText).mockResolvedValue({
translations: [{ translatedText: "Translated", targetLocale: "es" }],
failedLocales: [],
});
const payload = JSON.stringify({
workflowStepId: 1,
reminderBody: "Body text",
emailSubject: "Subject text",
sourceLocale: "en",
});
await translateWorkflowStepData(payload);
expect(mockTranslationService.translateText).toHaveBeenCalledTimes(2);
expect(mockWorkflowStepTranslationRepository.upsertManyBodyTranslations).toHaveBeenCalled();
expect(mockWorkflowStepTranslationRepository.upsertManySubjectTranslations).toHaveBeenCalled();
});
it("should not call repository when no translations are returned", async () => {
vi.mocked(mockPrisma.workflowStep.findUnique).mockResolvedValue({
reminderBody: "Hello",
emailSubject: null,
sourceLocale: "en",
});
vi.mocked(mockTranslationService.translateText).mockResolvedValue({
translations: [],
failedLocales: ["es", "de"],
});
const payload = JSON.stringify({
workflowStepId: 1,
reminderBody: "Hello",
emailSubject: null,
sourceLocale: "en",
});
await translateWorkflowStepData(payload);
expect(mockWorkflowStepTranslationRepository.upsertManyBodyTranslations).not.toHaveBeenCalled();
});
it("should throw on invalid payload", async () => {
await expect(translateWorkflowStepData("invalid-json")).rejects.toThrow();
});
it("should preserve targetLocale in translation data", async () => {
vi.mocked(mockPrisma.workflowStep.findUnique).mockResolvedValue({
reminderBody: "Hello",
emailSubject: null,
sourceLocale: "en",
});
vi.mocked(mockTranslationService.translateText).mockResolvedValue({
translations: [
{ translatedText: "Hola", targetLocale: "es" },
{ translatedText: "Bonjour", targetLocale: "fr" },
],
failedLocales: [],
});
const payload = JSON.stringify({
workflowStepId: 1,
reminderBody: "Hello",
emailSubject: null,
sourceLocale: "en",
});
await translateWorkflowStepData(payload);
expect(mockWorkflowStepTranslationRepository.upsertManyBodyTranslations).toHaveBeenCalledWith([
expect.objectContaining({ targetLocale: "es", translatedText: "Hola" }),
expect.objectContaining({ targetLocale: "fr", translatedText: "Bonjour" }),
]);
});
describe("stale task detection", () => {
it("should skip translation when workflow step is not found", async () => {
vi.mocked(mockPrisma.workflowStep.findUnique).mockResolvedValue(null);
const payload = JSON.stringify({
workflowStepId: 999,
reminderBody: "Hello",
emailSubject: null,
sourceLocale: "en",
});
await translateWorkflowStepData(payload);
expect(mockLogger.warn).toHaveBeenCalledWith(
"Workflow step 999 not found for translation task"
);
expect(mockTranslationService.translateText).not.toHaveBeenCalled();
});
it("should skip translation when source locale has changed", async () => {
vi.mocked(mockPrisma.workflowStep.findUnique).mockResolvedValue({
reminderBody: "Hello",
emailSubject: null,
sourceLocale: "fr", // Different from payload
});
const payload = JSON.stringify({
workflowStepId: 1,
reminderBody: "Hello",
emailSubject: null,
sourceLocale: "en",
});
await translateWorkflowStepData(payload);
expect(mockLogger.info).toHaveBeenCalledWith(
"Skipping stale translation task for workflow step 1"
);
expect(mockTranslationService.translateText).not.toHaveBeenCalled();
});
it("should skip translation when reminderBody has changed", async () => {
vi.mocked(mockPrisma.workflowStep.findUnique).mockResolvedValue({
reminderBody: "Updated body", // Different from payload
emailSubject: null,
sourceLocale: "en",
});
const payload = JSON.stringify({
workflowStepId: 1,
reminderBody: "Original body",
emailSubject: null,
sourceLocale: "en",
});
await translateWorkflowStepData(payload);
expect(mockLogger.info).toHaveBeenCalledWith(
"Skipping stale translation task for workflow step 1"
);
expect(mockTranslationService.translateText).not.toHaveBeenCalled();
});
it("should skip translation when emailSubject has changed", async () => {
vi.mocked(mockPrisma.workflowStep.findUnique).mockResolvedValue({
reminderBody: null,
emailSubject: "Updated subject", // Different from payload
sourceLocale: "en",
});
const payload = JSON.stringify({
workflowStepId: 1,
reminderBody: null,
emailSubject: "Original subject",
sourceLocale: "en",
});
await translateWorkflowStepData(payload);
expect(mockLogger.info).toHaveBeenCalledWith(
"Skipping stale translation task for workflow step 1"
);
expect(mockTranslationService.translateText).not.toHaveBeenCalled();
});
it("should only translate body when subject has changed but body matches", async () => {
vi.mocked(mockPrisma.workflowStep.findUnique).mockResolvedValue({
reminderBody: "Hello body",
emailSubject: "Updated subject", // Different from payload
sourceLocale: "en",
});
vi.mocked(mockTranslationService.translateText).mockResolvedValue({
translations: [{ translatedText: "Hola cuerpo", targetLocale: "es" }],
failedLocales: [],
});
const payload = JSON.stringify({
workflowStepId: 1,
reminderBody: "Hello body",
emailSubject: "Original subject",
sourceLocale: "en",
});
await translateWorkflowStepData(payload);
expect(mockTranslationService.translateText).toHaveBeenCalledTimes(1);
expect(mockTranslationService.translateText).toHaveBeenCalledWith({
text: "Hello body",
sourceLocale: "en",
});
expect(mockWorkflowStepTranslationRepository.upsertManyBodyTranslations).toHaveBeenCalled();
expect(mockWorkflowStepTranslationRepository.upsertManySubjectTranslations).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,98 @@
import { getTranslationService } from "@calcom/features/di/containers/TranslationService";
import { getWorkflowStepTranslationRepository } from "@calcom/features/ee/workflows/di/WorkflowStepTranslationRepository.container";
import { WorkflowStepRepository } from "@calcom/features/ee/workflows/repositories/WorkflowStepRepository";
import logger from "@calcom/lib/logger";
import prisma from "@calcom/prisma";
import { WorkflowStepAutoTranslatedField } from "@calcom/prisma/enums";
import { z } from "zod";
const ZTranslateWorkflowStepDataPayloadSchema = z.object({
workflowStepId: z.number(),
reminderBody: z.string().nullable().optional(),
emailSubject: z.string().nullable().optional(),
sourceLocale: z.string(),
});
async function processTranslations({
text,
sourceLocale,
workflowStepId,
field,
}: {
text: string;
sourceLocale: string;
workflowStepId: number;
field: WorkflowStepAutoTranslatedField;
}): Promise<void> {
try {
const translationService = await getTranslationService();
const result = await translationService.translateText({ text, sourceLocale });
if (result.translations.length > 0) {
const translationData = result.translations.map(({ translatedText, targetLocale }) => ({
workflowStepId,
sourceLocale,
targetLocale,
translatedText,
}));
const workflowStepTranslationRepository = getWorkflowStepTranslationRepository();
if (field === WorkflowStepAutoTranslatedField.REMINDER_BODY) {
await workflowStepTranslationRepository.upsertManyBodyTranslations(translationData);
} else {
await workflowStepTranslationRepository.upsertManySubjectTranslations(translationData);
}
}
if (result.failedLocales.length > 0) {
logger.warn(
`Failed to translate workflow step ${field} to locales: ${result.failedLocales.join(", ")}`
);
}
} catch (error) {
logger.error(`Failed to process workflow step ${field} translations:`, error);
}
}
async function translateWorkflowStepData(payload: string): Promise<void> {
const { workflowStepId, reminderBody, emailSubject, sourceLocale } =
ZTranslateWorkflowStepDataPayloadSchema.parse(JSON.parse(payload));
const workflowStepRepository = new WorkflowStepRepository(prisma);
const workflowStep = await workflowStepRepository.findTranslationDataById(workflowStepId);
if (!workflowStep) {
logger.warn(`Workflow step ${workflowStepId} not found for translation task`);
return;
}
const sourceLocaleMatches = workflowStep.sourceLocale === sourceLocale;
const shouldTranslateBody =
Boolean(reminderBody) && sourceLocaleMatches && workflowStep.reminderBody === reminderBody;
const shouldTranslateSubject =
Boolean(emailSubject) && sourceLocaleMatches && workflowStep.emailSubject === emailSubject;
if (!shouldTranslateBody && !shouldTranslateSubject) {
logger.info(`Skipping stale translation task for workflow step ${workflowStepId}`);
return;
}
await Promise.all([
shouldTranslateBody &&
processTranslations({
text: reminderBody as string,
sourceLocale,
workflowStepId,
field: WorkflowStepAutoTranslatedField.REMINDER_BODY,
}),
shouldTranslateSubject &&
processTranslations({
text: emailSubject as string,
sourceLocale,
workflowStepId,
field: WorkflowStepAutoTranslatedField.EMAIL_SUBJECT,
}),
]);
}
export { ZTranslateWorkflowStepDataPayloadSchema, translateWorkflowStepData };
@@ -0,0 +1,8 @@
export const TRANSLATION_DI_TOKENS = {
TRANSLATION_SERVICE: Symbol("TranslationService"),
TRANSLATION_SERVICE_MODULE: Symbol("TranslationServiceModule"),
WORKFLOW_STEP_TRANSLATION_REPOSITORY: Symbol("WorkflowStepTranslationRepository"),
WORKFLOW_STEP_TRANSLATION_REPOSITORY_MODULE: Symbol("WorkflowStepTranslationRepositoryModule"),
EVENT_TYPE_TRANSLATION_REPOSITORY: Symbol("EventTypeTranslationRepository"),
EVENT_TYPE_TRANSLATION_REPOSITORY_MODULE: Symbol("EventTypeTranslationRepositoryModule"),
};
@@ -0,0 +1,51 @@
import type { TranslationSupportedLocale } from "@calcom/lib/translationConstants";
export type TranslationResult = {
translatedText: string;
targetLocale: TranslationSupportedLocale;
};
export type TranslateTextParams = {
text: string;
sourceLocale: string;
};
export type TranslateTextResult = {
translations: TranslationResult[];
failedLocales: string[];
};
export type WorkflowStepTranslationLookupResult = {
translatedBody?: string;
translatedSubject?: string;
};
export type WorkflowStepTranslationLookupOptions = {
includeBody?: boolean;
includeSubject?: boolean;
};
export type EventTypeTranslationLookupResult = {
translatedTitle?: string;
translatedDescription?: string;
};
export type EventTypeTranslationLookupOptions = {
includeTitle?: boolean;
includeDescription?: boolean;
};
export interface ITranslationService {
translateText(params: TranslateTextParams): Promise<TranslateTextResult>;
getTargetLocales(sourceLocale: string): TranslationSupportedLocale[];
getWorkflowStepTranslation(
workflowStepId: number,
targetLocale: string,
options?: WorkflowStepTranslationLookupOptions
): Promise<WorkflowStepTranslationLookupResult>;
getEventTypeTranslation(
eventTypeId: number,
targetLocale: string,
options?: EventTypeTranslationLookupOptions
): Promise<EventTypeTranslationLookupResult>;
}
@@ -0,0 +1,263 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { TranslationService } from "./TranslationService";
vi.mock("@calcom/lib/logger", () => ({
default: { error: vi.fn() },
}));
describe("TranslationService", () => {
let mockLocalizeText: ReturnType<typeof vi.fn>;
let mockWorkflowStepTranslationRepository: {
findByLocale: ReturnType<typeof vi.fn>;
};
let mockEventTypeTranslationRepository: {
findByLocale: ReturnType<typeof vi.fn>;
};
let service: TranslationService;
beforeEach(() => {
vi.resetAllMocks();
mockLocalizeText = vi.fn();
mockWorkflowStepTranslationRepository = {
findByLocale: vi.fn(),
};
mockEventTypeTranslationRepository = {
findByLocale: vi.fn(),
};
service = new TranslationService({
localizeText: mockLocalizeText,
workflowStepTranslationRepository: mockWorkflowStepTranslationRepository as never,
eventTypeTranslationRepository: mockEventTypeTranslationRepository as never,
});
});
describe("getTargetLocales", () => {
it("should exclude source locale from target locales", () => {
const targets = service.getTargetLocales("en");
expect(targets).not.toContain("en");
});
it("should return supported locales that are in i18n", () => {
const targets = service.getTargetLocales("en");
expect(targets.length).toBeGreaterThan(0);
expect(targets).toContain("es");
expect(targets).toContain("de");
});
it("should exclude different source locale", () => {
const targets = service.getTargetLocales("es");
expect(targets).not.toContain("es");
expect(targets).toContain("en");
});
});
describe("translateText", () => {
it("should return empty results for empty text", async () => {
const result = await service.translateText({ text: "", sourceLocale: "en" });
expect(result.translations).toHaveLength(0);
expect(result.failedLocales).toHaveLength(0);
expect(mockLocalizeText).not.toHaveBeenCalled();
});
it("should return empty results for whitespace-only text", async () => {
const result = await service.translateText({ text: " ", sourceLocale: "en" });
expect(result.translations).toHaveLength(0);
expect(result.failedLocales).toHaveLength(0);
expect(mockLocalizeText).not.toHaveBeenCalled();
});
it("should return empty results for null text", async () => {
const result = await service.translateText({ text: null as unknown as string, sourceLocale: "en" });
expect(result.translations).toHaveLength(0);
expect(result.failedLocales).toHaveLength(0);
});
it("should translate text to all supported locales", async () => {
mockLocalizeText.mockResolvedValue("Translated");
const result = await service.translateText({ text: "Hello", sourceLocale: "en" });
expect(result.translations.length).toBeGreaterThan(0);
expect(result.translations[0]).toEqual(
expect.objectContaining({
translatedText: "Translated",
})
);
});
it("should filter out null translations and track failed locales", async () => {
mockLocalizeText
.mockResolvedValueOnce("Hola")
.mockResolvedValueOnce(null)
.mockResolvedValue("Translated");
const result = await service.translateText({ text: "Hello", sourceLocale: "en" });
expect(result.failedLocales.length).toBe(1);
expect(result.translations.every((t) => t.translatedText !== null)).toBe(true);
});
it("should skip source locale when translating", async () => {
mockLocalizeText.mockResolvedValue("Translation");
await service.translateText({ text: "Hello", sourceLocale: "es" });
const calls = mockLocalizeText.mock.calls;
const targetLocales = calls.map((call) => call[2]);
expect(targetLocales).not.toContain("es");
});
it("should handle translation service errors gracefully", async () => {
mockLocalizeText.mockRejectedValue(new Error("API Error"));
const result = await service.translateText({ text: "Hello", sourceLocale: "en" });
expect(result.translations).toHaveLength(0);
expect(result.failedLocales.length).toBeGreaterThan(0);
});
it("should preserve locale alignment in results", async () => {
mockLocalizeText.mockImplementation((_text, _source, target) =>
Promise.resolve(`Translated to ${target}`)
);
const result = await service.translateText({ text: "Hello", sourceLocale: "en" });
for (const translation of result.translations) {
expect(translation.translatedText).toBe(`Translated to ${translation.targetLocale}`);
}
});
it("should call localizeText with correct parameters", async () => {
mockLocalizeText.mockResolvedValue("Translated");
await service.translateText({ text: "Hello World", sourceLocale: "en" });
expect(mockLocalizeText).toHaveBeenCalledWith("Hello World", "en", expect.any(String));
});
});
describe("getWorkflowStepTranslation", () => {
it("should return translated body when includeBody is true", async () => {
mockWorkflowStepTranslationRepository.findByLocale.mockResolvedValue({
translatedText: "Translated body",
});
const result = await service.getWorkflowStepTranslation(1, "es", { includeBody: true });
expect(result.translatedBody).toBe("Translated body");
expect(mockWorkflowStepTranslationRepository.findByLocale).toHaveBeenCalledWith(
1,
"REMINDER_BODY",
"es"
);
});
it("should return translated subject when includeSubject is true", async () => {
mockWorkflowStepTranslationRepository.findByLocale.mockResolvedValue({
translatedText: "Translated subject",
});
const result = await service.getWorkflowStepTranslation(1, "es", { includeSubject: true });
expect(result.translatedSubject).toBe("Translated subject");
expect(mockWorkflowStepTranslationRepository.findByLocale).toHaveBeenCalledWith(
1,
"EMAIL_SUBJECT",
"es"
);
});
it("should return both body and subject when both options are true", async () => {
mockWorkflowStepTranslationRepository.findByLocale
.mockResolvedValueOnce({ translatedText: "Translated body" })
.mockResolvedValueOnce({ translatedText: "Translated subject" });
const result = await service.getWorkflowStepTranslation(1, "es", {
includeBody: true,
includeSubject: true,
});
expect(result.translatedBody).toBe("Translated body");
expect(result.translatedSubject).toBe("Translated subject");
});
it("should return empty object when no translation found", async () => {
mockWorkflowStepTranslationRepository.findByLocale.mockResolvedValue(null);
const result = await service.getWorkflowStepTranslation(1, "es", { includeBody: true });
expect(result.translatedBody).toBeUndefined();
});
it("should default to includeBody: true when no options provided", async () => {
mockWorkflowStepTranslationRepository.findByLocale.mockResolvedValue({
translatedText: "Translated body",
});
await service.getWorkflowStepTranslation(1, "es");
expect(mockWorkflowStepTranslationRepository.findByLocale).toHaveBeenCalledWith(
1,
"REMINDER_BODY",
"es"
);
});
});
describe("getEventTypeTranslation", () => {
it("should return translated description when includeDescription is true", async () => {
mockEventTypeTranslationRepository.findByLocale.mockResolvedValue({
translatedText: "Translated description",
});
const result = await service.getEventTypeTranslation(1, "es", { includeDescription: true });
expect(result.translatedDescription).toBe("Translated description");
expect(mockEventTypeTranslationRepository.findByLocale).toHaveBeenCalledWith(1, "DESCRIPTION", "es");
});
it("should return translated title when includeTitle is true", async () => {
mockEventTypeTranslationRepository.findByLocale.mockResolvedValue({
translatedText: "Translated title",
});
const result = await service.getEventTypeTranslation(1, "es", { includeTitle: true });
expect(result.translatedTitle).toBe("Translated title");
expect(mockEventTypeTranslationRepository.findByLocale).toHaveBeenCalledWith(1, "TITLE", "es");
});
it("should return both title and description when both options are true", async () => {
mockEventTypeTranslationRepository.findByLocale
.mockResolvedValueOnce({ translatedText: "Translated title" })
.mockResolvedValueOnce({ translatedText: "Translated description" });
const result = await service.getEventTypeTranslation(1, "es", {
includeTitle: true,
includeDescription: true,
});
expect(result.translatedTitle).toBe("Translated title");
expect(result.translatedDescription).toBe("Translated description");
});
it("should return empty object when no translation found", async () => {
mockEventTypeTranslationRepository.findByLocale.mockResolvedValue(null);
const result = await service.getEventTypeTranslation(1, "es", { includeDescription: true });
expect(result.translatedDescription).toBeUndefined();
});
it("should default to includeDescription: true when no options provided", async () => {
mockEventTypeTranslationRepository.findByLocale.mockResolvedValue({
translatedText: "Translated description",
});
await service.getEventTypeTranslation(1, "es");
expect(mockEventTypeTranslationRepository.findByLocale).toHaveBeenCalledWith(1, "DESCRIPTION", "es");
});
});
});
@@ -0,0 +1,144 @@
import type { WorkflowStepTranslationRepository } from "@calcom/features/ee/workflows/repositories/WorkflowStepTranslationRepository";
import type { EventTypeTranslationRepository } from "@calcom/features/eventTypeTranslation/repositories/EventTypeTranslationRepository";
import { locales as i18nLocales } from "@calcom/lib/i18n";
import logger from "@calcom/lib/logger";
import {
TRANSLATION_SUPPORTED_LOCALES,
type TranslationSupportedLocale,
} from "@calcom/lib/translationConstants";
import { EventTypeAutoTranslatedField, WorkflowStepAutoTranslatedField } from "@calcom/prisma/enums";
import type {
EventTypeTranslationLookupOptions,
EventTypeTranslationLookupResult,
ITranslationService,
TranslateTextParams,
TranslateTextResult,
TranslationResult,
WorkflowStepTranslationLookupOptions,
WorkflowStepTranslationLookupResult,
} from "./ITranslationService";
export interface ITranslationServiceDeps {
localizeText: (text: string, sourceLocale: string, targetLocale: string) => Promise<string | null>;
workflowStepTranslationRepository: WorkflowStepTranslationRepository;
eventTypeTranslationRepository: EventTypeTranslationRepository;
}
export class TranslationService implements ITranslationService {
constructor(private deps: ITranslationServiceDeps) {}
getTargetLocales(sourceLocale: string): TranslationSupportedLocale[] {
return TRANSLATION_SUPPORTED_LOCALES.filter(
(locale) => locale !== sourceLocale && i18nLocales.includes(locale)
);
}
async translateText(params: TranslateTextParams): Promise<TranslateTextResult> {
const { text, sourceLocale } = params;
if (!text?.trim()) {
return { translations: [], failedLocales: [] };
}
const targetLocales = this.getTargetLocales(sourceLocale);
const failedLocales: string[] = [];
try {
const translationPromises = targetLocales.map(async (targetLocale) => {
const translatedText = await this.deps.localizeText(text, sourceLocale, targetLocale);
return { translatedText, targetLocale };
});
const results = await Promise.all(translationPromises);
const translations: TranslationResult[] = [];
for (const result of results) {
if (result.translatedText !== null) {
translations.push({
translatedText: result.translatedText,
targetLocale: result.targetLocale,
});
} else {
failedLocales.push(result.targetLocale);
}
}
return { translations, failedLocales };
} catch (error) {
logger.error("TranslationService.translateText() failed:", error);
return { translations: [], failedLocales: targetLocales };
}
}
async getWorkflowStepTranslation(
workflowStepId: number,
targetLocale: string,
options: WorkflowStepTranslationLookupOptions = { includeBody: true, includeSubject: false }
): Promise<WorkflowStepTranslationLookupResult> {
const result: WorkflowStepTranslationLookupResult = {};
const promises: Promise<void>[] = [];
if (options.includeBody) {
promises.push(
this.deps.workflowStepTranslationRepository
.findByLocale(workflowStepId, WorkflowStepAutoTranslatedField.REMINDER_BODY, targetLocale)
.then((translation) => {
if (translation?.translatedText) {
result.translatedBody = translation.translatedText;
}
})
);
}
if (options.includeSubject) {
promises.push(
this.deps.workflowStepTranslationRepository
.findByLocale(workflowStepId, WorkflowStepAutoTranslatedField.EMAIL_SUBJECT, targetLocale)
.then((translation) => {
if (translation?.translatedText) {
result.translatedSubject = translation.translatedText;
}
})
);
}
await Promise.all(promises);
return result;
}
async getEventTypeTranslation(
eventTypeId: number,
targetLocale: string,
options: EventTypeTranslationLookupOptions = { includeTitle: false, includeDescription: true }
): Promise<EventTypeTranslationLookupResult> {
const result: EventTypeTranslationLookupResult = {};
const promises: Promise<void>[] = [];
if (options.includeTitle) {
promises.push(
this.deps.eventTypeTranslationRepository
.findByLocale(eventTypeId, EventTypeAutoTranslatedField.TITLE, targetLocale)
.then((translation) => {
if (translation?.translatedText) {
result.translatedTitle = translation.translatedText;
}
})
);
}
if (options.includeDescription) {
promises.push(
this.deps.eventTypeTranslationRepository
.findByLocale(eventTypeId, EventTypeAutoTranslatedField.DESCRIPTION, targetLocale)
.then((translation) => {
if (translation?.translatedText) {
result.translatedDescription = translation.translatedText;
}
})
);
}
await Promise.all(promises);
return result;
}
}
+3
View File
@@ -2956,6 +2956,9 @@
"team_no_event_types": "This team has no event types",
"seat_options_doesnt_multiple_durations": "Seat option doesn't support multiple durations",
"include_calendar_event": "Include calendar event",
"auto_translate_for_attendees": "Auto-translate for attendees",
"auto_translate_source_language_hint": "Content will be translated from {{language}}",
"upgrade_to_organizations": "Upgrade to Organizations",
"oAuth": "OAuth",
"oAuth_client_creation_form": "OAuth client creation form",
"oAuth_client_creation_form_description": "This is the form to create a new OAuth client",
+27
View File
@@ -0,0 +1,27 @@
/**
* Supported locales for auto-translation features.
* Used by both event type and workflow step translations.
*/
export const TRANSLATION_SUPPORTED_LOCALES = [
"en", // English
"es", // Spanish
"de", // German
"pt", // Portuguese
"pt-BR", // Portuguese Brazilian
"fr", // French
"it", // Italian
"ar", // Arabic
"ru", // Russian
"zh-CN", // Simplified Chinese
"nl", // Dutch
"zh-TW", // Traditional Chinese
"ko", // Korean
"ja", // Japanese
"sv", // Swedish
"da", // Danish
"is", // Icelandic
"lt", // Lithuanian
"nb", // Norwegian Bokmål
] as const;
export type TranslationSupportedLocale = (typeof TRANSLATION_SUPPORTED_LOCALES)[number];
@@ -0,0 +1,26 @@
-- CreateEnum
CREATE TYPE "public"."WorkflowStepAutoTranslatedField" AS ENUM ('REMINDER_BODY', 'EMAIL_SUBJECT');
-- AlterTable
ALTER TABLE "public"."WorkflowStep" ADD COLUMN "autoTranslateEnabled" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "sourceLocale" TEXT;
-- CreateTable
CREATE TABLE "public"."WorkflowStepTranslation" (
"uid" TEXT NOT NULL,
"workflowStepId" INTEGER NOT NULL,
"field" "public"."WorkflowStepAutoTranslatedField" NOT NULL,
"sourceLocale" TEXT NOT NULL,
"targetLocale" TEXT NOT NULL,
"translatedText" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WorkflowStepTranslation_pkey" PRIMARY KEY ("uid")
);
-- CreateIndex
CREATE UNIQUE INDEX "WorkflowStepTranslation_workflowStepId_field_targetLocale_key" ON "public"."WorkflowStepTranslation"("workflowStepId", "field", "targetLocale");
-- AddForeignKey
ALTER TABLE "public"."WorkflowStepTranslation" ADD CONSTRAINT "WorkflowStepTranslation_workflowStepId_fkey" FOREIGN KEY ("workflowStepId") REFERENCES "public"."WorkflowStep"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+24 -1
View File
@@ -289,7 +289,7 @@ model EventType {
bookingRequiresAuthentication Boolean @default(false)
rrHostSubsetEnabled Boolean @default(false)
requiresCancellationReason CancellationReasonRequirement? @default(MANDATORY_HOST_ONLY)
enablePerHostLocations Boolean @default(false)
enablePerHostLocations Boolean @default(false)
createdAt DateTime? @default(now())
updatedAt DateTime? @updatedAt
@@ -1546,6 +1546,10 @@ model WorkflowStep {
inboundAgentId String? @unique
inboundAgent Agent? @relation("WorkflowStepInboundAgent", fields: [inboundAgentId], references: [id], onDelete: SetNull)
autoTranslateEnabled Boolean @default(false)
sourceLocale String?
translations WorkflowStepTranslation[]
@@index([workflowId])
}
@@ -2370,6 +2374,11 @@ enum EventTypeAutoTranslatedField {
TITLE
}
enum WorkflowStepAutoTranslatedField {
REMINDER_BODY
EMAIL_SUBJECT
}
model DelegationCredential {
id String @id @default(uuid())
workspacePlatform WorkspacePlatform @relation(fields: [workspacePlatformId], references: [id], onDelete: Cascade)
@@ -2459,6 +2468,20 @@ model EventTypeTranslation {
@@index([eventTypeId, field, targetLocale])
}
model WorkflowStepTranslation {
uid String @id @default(cuid())
workflowStep WorkflowStep @relation(fields: [workflowStepId], references: [id], onDelete: Cascade)
workflowStepId Int
field WorkflowStepAutoTranslatedField
sourceLocale String
targetLocale String
translatedText String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([workflowStepId, field, targetLocale])
}
enum WatchlistType {
EMAIL
DOMAIN
@@ -1,7 +1,12 @@
import { createDefaultAIPhoneServiceProvider } from "@calcom/features/calAIPhone";
import { PrismaAgentRepository } from "@calcom/features/calAIPhone/repositories/PrismaAgentRepository";
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
import { isEmailAction, isFormTrigger } from "@calcom/features/ee/workflows/lib/actionHelperFunctions";
import {
isAttendeeAction,
isEmailAction,
isFormTrigger,
isWhatsappAction,
} from "@calcom/features/ee/workflows/lib/actionHelperFunctions";
import { deleteRemindersOfActiveOnIds } from "@calcom/features/ee/workflows/lib/deleteRemindersOfActiveOnIds";
import { isAuthorized } from "@calcom/features/ee/workflows/lib/isAuthorized";
import { WorkflowReminderRepository } from "@calcom/features/ee/workflows/lib/repository/workflowReminder";
@@ -16,29 +21,29 @@ import { addPermissionsToWorkflow } from "@calcom/features/workflows/repositorie
import { IS_SELF_HOSTED, SCANNING_WORKFLOW_STEPS } from "@calcom/lib/constants";
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
import logger from "@calcom/lib/logger";
import { prisma, type PrismaClient } from "@calcom/prisma";
import { WorkflowActions, WorkflowTemplates } from "@calcom/prisma/enums";
import { PhoneNumberSubscriptionStatus } from "@calcom/prisma/enums";
import { type PrismaClient, prisma } from "@calcom/prisma";
import { PhoneNumberSubscriptionStatus, WorkflowActions, WorkflowTemplates } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server";
import hasActiveTeamPlanHandler from "../teams/hasActiveTeamPlan.handler";
import type { TUpdateInputSchema } from "./update.schema";
import {
getSender,
upsertSmsReminderFieldForEventTypes,
isAuthorizedToAddActiveOnIds,
removeSmsReminderFieldForEventTypes,
isStepEdited,
getEmailTemplateText,
upsertAIAgentCallPhoneNumberFieldForEventTypes,
getSender,
isAuthorizedToAddActiveOnIds,
isStepEdited,
removeAIAgentCallPhoneNumberFieldForEventTypes,
removeSmsReminderFieldForEventTypes,
upsertAIAgentCallPhoneNumberFieldForEventTypes,
upsertSmsReminderFieldForEventTypes,
} from "./util";
type UpdateOptions = {
ctx: {
user: Pick<NonNullable<TrpcSessionUser>, "id" | "metadata" | "locale" | "timeFormat" | "timeZone">;
user: Pick<
NonNullable<TrpcSessionUser>,
"id" | "metadata" | "locale" | "timeFormat" | "timeZone" | "organizationId"
>;
prisma: PrismaClient;
};
input: TUpdateInputSchema;
@@ -159,9 +164,9 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
userWorkflow.teamId
);
activeOnWithChildren = activeOnEventTypes
.map((eventType) => [eventType.id].concat(eventType.children.map((child) => child.id)))
.flat();
activeOnWithChildren = activeOnEventTypes.flatMap((eventType) =>
[eventType.id].concat(eventType.children.map((child) => child.id))
);
let oldActiveOnEventTypes: { id: number; children: { id: number }[] }[];
if (userWorkflow.isActiveOnAll) {
@@ -415,6 +420,8 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
verifiedAt: oldStep.verifiedAt,
agentId: newStep.agentId || null,
inboundAgentId: newStep.inboundAgentId || null,
sourceLocale: newStep.sourceLocale ?? null,
autoTranslateEnabled: newStep.autoTranslateEnabled ?? false,
})
) {
// check if step that require team plan already existed before
@@ -458,6 +465,14 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
}
const didBodyChange = newStep.reminderBody !== oldStep.reminderBody;
const didSubjectChange = newStep.emailSubject !== oldStep.emailSubject;
const didSourceLocaleChange = newStep.sourceLocale !== oldStep.sourceLocale;
const didAutoTranslateEnable = !oldStep.autoTranslateEnabled && Boolean(newStep.autoTranslateEnabled);
const nextVerifiedAt = (() => {
if (!SCANNING_WORKFLOW_STEPS) return new Date();
if (didBodyChange) return null;
return oldStep.verifiedAt;
})();
await workflowStepRepository.updateWorkflowStep(oldStep.id, {
action: newStep.action,
@@ -471,9 +486,28 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
numberVerificationPending: false,
includeCalendarEvent: newStep.includeCalendarEvent,
agentId: newStep.agentId || null,
verifiedAt: !SCANNING_WORKFLOW_STEPS ? new Date() : didBodyChange ? null : oldStep.verifiedAt,
verifiedAt: nextVerifiedAt,
autoTranslateEnabled: ctx.user.organizationId ? (newStep.autoTranslateEnabled ?? false) : false,
sourceLocale: newStep.sourceLocale || ctx.user.locale,
});
const shouldTranslate =
ctx.user.organizationId &&
newStep.autoTranslateEnabled &&
isAttendeeAction(newStep.action) &&
!isWhatsappAction(newStep.action) &&
(newStep.reminderBody || newStep.emailSubject) &&
(didBodyChange || didSubjectChange || didSourceLocaleChange || didAutoTranslateEnable);
if (shouldTranslate) {
await tasker.create("translateWorkflowStepData", {
workflowStepId: oldStep.id,
reminderBody: newStep.reminderBody,
emailSubject: newStep.emailSubject,
sourceLocale: newStep.sourceLocale || ctx.user.locale,
});
}
if (SCANNING_WORKFLOW_STEPS && didBodyChange) {
await tasker.create("scanWorkflowBody", {
workflowStepId: oldStep.id,
@@ -485,7 +519,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
await scheduleWorkflowNotifications({
activeOn: activeOnEventTypeIds,
isOrg,
workflowSteps: [newStep],
workflowSteps: [{ ...newStep, verifiedAt: nextVerifiedAt }],
time,
timeUnit,
trigger,
@@ -532,6 +566,8 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
}),
id: undefined,
senderName: undefined,
autoTranslateEnabled: ctx.user.organizationId ? (newStep.autoTranslateEnabled ?? false) : false,
sourceLocale: newStep.sourceLocale || ctx.user.locale,
};
})
);
@@ -559,7 +595,30 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
})
)
);
} else if (!isFormTrigger(trigger)) {
}
if (ctx.user.organizationId) {
await Promise.all(
createdSteps
.filter(
(step) =>
step.autoTranslateEnabled &&
isAttendeeAction(step.action) &&
!isWhatsappAction(step.action) &&
(step.reminderBody || step.emailSubject)
)
.map((step) =>
tasker.create("translateWorkflowStepData", {
workflowStepId: step.id,
reminderBody: step.reminderBody,
emailSubject: step.emailSubject,
sourceLocale: step.sourceLocale || ctx.user.locale,
})
)
);
}
if (!SCANNING_WORKFLOW_STEPS && !isFormTrigger(trigger)) {
// schedule notification for new step (only for event-based triggers)
await scheduleWorkflowNotifications({
activeOn: activeOnEventTypeIds,
@@ -24,6 +24,8 @@ const stepSchema = z.object({
includeCalendarEvent: z.boolean(),
agentId: z.string().nullish(),
inboundAgentId: z.string().nullish(),
autoTranslateEnabled: z.boolean().optional(),
sourceLocale: z.string().nullish(),
});
export const ZUpdateInputSchema = z
+40
View File
@@ -0,0 +1,40 @@
# CLAUDE.md — Workflow Translation
## Project Context
Add auto-translation for workflow body and subject using lingo.dev, matching the existing event type translation pattern.
## Before Starting Work
1. Read specs/workflow-translation/design.md
2. Check specs/workflow-translation/implementation.md for current progress
3. Study existing patterns in:
- `packages/features/tasker/tasks/translateEventTypeData.ts`
- `packages/features/eventTypeTranslation/repositories/EventTypeTranslationRepository.ts`
- `packages/lib/server/service/lingoDotDev.ts`
## Code Patterns
**Repository pattern**: Follow `EventTypeTranslationRepository` for DB operations
**Tasker pattern**: Follow `translateEventTypeData.ts` for async translation task
**Translation lookup**: In email/SMS services, lookup translation before `customTemplate()` call
## Reference Files
| Purpose | File |
|---------|------|
| Lingo.dev service | `packages/lib/server/service/lingoDotDev.ts` |
| Event type translation task | `packages/features/tasker/tasks/translateEventTypeData.ts` |
| Event type translation repo | `packages/features/eventTypeTranslation/repositories/EventTypeTranslationRepository.ts` |
| Email workflow service | `packages/features/ee/workflows/lib/service/EmailWorkflowService.ts` |
| SMS manager | `packages/features/ee/workflows/lib/reminders/smsReminderManager.ts` |
| Workflow update handler | `packages/trpc/server/routers/viewer/workflows/update.handler.ts` |
## Don't
- Don't translate default templates (REMINDER, RATING) - they use i18n
- Don't support non-attendee actions (no locale available)
- Don't add features not in design.md
- Don't skip updating implementation.md after each piece
+42
View File
@@ -0,0 +1,42 @@
# Workflow Translation Decisions
## ADR-001: Pre-translate vs On-demand Translation
### Context
Need to decide when translations are generated - at workflow save time or at send time.
### Options Considered
1. **On-demand at send time** — Translate when sending to attendee, cache for reuse
- Pros: Fewer API calls, no unused translations
- Cons: Slight delay on first send to new locale
2. **Pre-translate at save time** — Translate to all 19 locales when workflow saved
- Pros: No delay at send time, matches event type pattern
- Cons: More upfront API calls
### Decision
Pre-translate at save time (option 2) to match the existing event type translation pattern and ensure no delay when sending notifications.
### Consequences
- Translations generated via async tasker task when workflow step is saved
- ~36 API calls per workflow step (18 locales × 2 fields)
- Database stores all translations upfront
## ADR-002: Organization-only Feature
### Context
Need to decide if feature is available to all users or restricted.
### Decision
Organization users only, matching the event type auto-translation feature.
### Consequences
- UI toggle only shown for org users
- Handler checks `ctx.user.organizationId` before triggering translation
+102
View File
@@ -0,0 +1,102 @@
# Workflow Translation Design
## Overview
Translate workflow `reminderBody` and `emailSubject` to the visitor's browser language using lingo.dev. Follows the existing pattern for event type title/description translation.
## Problem Statement
Workflow notifications are sent in the language they were written, not the attendee's language. Organizations need attendees to receive notifications in their preferred language.
## User Stories
- As an org admin, I want workflow notifications translated to the attendee's language so international attendees understand the message
- As an attendee, I want to receive booking notifications in my browser language
## Technical Design
### Database Changes
Add to `schema.prisma`:
```prisma
enum WorkflowStepAutoTranslatedField {
REMINDER_BODY
EMAIL_SUBJECT
}
model WorkflowStepTranslation {
uid String @id @default(cuid())
workflowStep WorkflowStep @relation(fields: [workflowStepId], references: [id], onDelete: Cascade)
workflowStepId Int
field WorkflowStepAutoTranslatedField
sourceLocale String
targetLocale String
translatedText String @db.Text
sourceHash String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([workflowStepId, field, targetLocale])
@@index([workflowStepId, field, targetLocale])
}
```
Add to WorkflowStep:
- `autoTranslateEnabled: Boolean @default(false)`
- `sourceLocale: String?`
- `translations: WorkflowStepTranslation[]`
### API Changes
**Workflow update handler** (`packages/trpc/server/routers/viewer/workflows/update.handler.ts`):
- Accept `autoTranslateEnabled` and `sourceLocale` fields
- Trigger `translateWorkflowStepData` tasker task when content changes
**New tasker task** (`packages/features/tasker/tasks/translateWorkflowStepData.ts`):
- Uses `TranslationService` to translate body/subject to 19 target locales
- Store translations in `WorkflowStepTranslation` table via repository
### TranslationService
Shared service at `packages/features/translation/services/TranslationService.ts`:
- `translateText()` - Translates text to all supported locales using LingoDotDevService
- `getWorkflowStepTranslations()` - Looks up cached translations for workflow steps
- `getEventTypeTranslations()` - Looks up cached translations for event types
- Uses DI pattern with repositories injected via constructor
### Send-Time Integration
**EmailWorkflowService** (`generateEmailPayloadForEvtWorkflow`):
- Uses `TranslationService.getWorkflowStepTranslations()` to lookup translation
- Fallback to original text if no translation found
**smsReminderManager** / **whatsappReminderManager**:
- Same pattern: uses TranslationService for attendee locale lookup
### UI Changes
**WorkflowStepContainer.tsx**:
- Add "Auto-translate for attendees" toggle
- Only show for org users and attendee-targeted actions
## Supported Locales
19 locales (same as event types):
en, es, de, pt, pt-BR, fr, it, ar, ru, zh-CN, zh-TW, ko, ja, nl, sv, da, is, lt, nb
## Scope
**Supported actions**: EMAIL_ATTENDEE, SMS_ATTENDEE, WHATSAPP_ATTENDEE
**Not supported**: EMAIL_HOST, EMAIL_ADDRESS, SMS_NUMBER (no attendee locale available)
## Out of Scope
- Form-based workflows (FORM_SUBMITTED triggers)
- CAL_AI_PHONE_CALL action
- Translation of default templates (already use i18n)
+39
View File
@@ -0,0 +1,39 @@
# Workflow Translation
## Overview
Auto-translate workflow notifications to the attendee's browser language using lingo.dev.
## How to Use
### Step 1: Enable Auto-translate
When editing a workflow step (EMAIL_ATTENDEE, SMS_ATTENDEE, or WHATSAPP_ATTENDEE), toggle "Auto-translate for attendees".
### Step 2: Translations Generated
Translations to 19 languages are generated automatically when you save the workflow.
### Step 3: Attendees Receive Translated Notifications
When a booking is made, the attendee receives notifications in their browser language (if supported).
## Configuration Options
| Option | Description | Default |
|--------|-------------|---------|
| Auto-translate for attendees | Enable/disable translation | Off |
## FAQ
### Which languages are supported?
English, Spanish, German, Portuguese, French, Italian, Arabic, Russian, Chinese (Simplified & Traditional), Japanese, Korean, Dutch, Swedish, Danish, Icelandic, Lithuanian, Norwegian.
### What happens if attendee's language isn't supported?
They receive the original notification in the language it was written.
### Are template variables translated?
No, variables like {EVENT_NAME} are preserved and substituted after translation.
Whitespace-only changes.
+16
View File
@@ -0,0 +1,16 @@
# Workflow Translation Future Work
Ideas and enhancements deferred from initial implementation.
## Enhancements
- Support form-based workflows (FORM_SUBMITTED) if submitter locale is captured
- Support EMAIL_ADDRESS action if recipient locale can be specified
- Translation preview in workflow editor
## Technical Debt
## Nice to Have
- Translation quality feedback mechanism
- Support for more locales beyond the initial 19
@@ -0,0 +1,59 @@
# Workflow Translation Implementation
## Status: complete
## Completed
- [x] Database schema: Added `WorkflowStepAutoTranslatedField` enum
- [x] Database schema: Added `WorkflowStepTranslation` model
- [x] Database schema: Added `autoTranslateEnabled`, `sourceLocale`, `translations` to WorkflowStep
- [x] Created WorkflowStepTranslationRepository (with DI/Prisma constructor injection)
- [x] Created translateWorkflowStepData tasker task
- [x] Integrated translation lookup in EmailWorkflowService
- [x] Integrated translation lookup in smsReminderManager
- [x] Integrated translation lookup in whatsappReminderManager
- [x] Updated workflow update handler to trigger translation
- [x] Added UI toggle in WorkflowStepContainer
- [x] Added unit tests
- [x] Created shared TranslationService with DI
## Files Changed
### New Files
- `packages/features/translation/services/TranslationService.ts` - Shared translation service
- `packages/features/translation/services/ITranslationService.ts` - Interface
- `packages/features/translation/di/tokens.ts` - DI tokens
- `packages/features/di/modules/TranslationService.ts` - DI module
- `packages/features/di/containers/TranslationService.ts` - DI container
- `packages/features/ee/workflows/repositories/WorkflowStepTranslationRepository.ts`
- `packages/features/ee/workflows/di/WorkflowStepTranslationRepository.module.ts` - DI module
- `packages/features/ee/workflows/di/WorkflowStepTranslationRepository.container.ts` - DI container
- `packages/features/tasker/tasks/translateWorkflowStepData.ts`
- `packages/features/tasker/tasks/translateWorkflowStepData.test.ts`
### Modified Files
- `packages/prisma/schema.prisma` - Added WorkflowStepTranslation model and WorkflowStep fields
- `packages/features/tasker/tasker.ts` - Register translateWorkflowStepData task
- `packages/features/tasker/tasks/index.ts` - Export translateWorkflowStepData
- `packages/features/ee/workflows/lib/service/EmailWorkflowService.ts` - Translation lookup at send time
- `packages/features/ee/workflows/lib/service/EmailWorkflowService.test.ts` - Auto translation tests
- `packages/features/ee/workflows/lib/reminders/smsReminderManager.ts` - Translation lookup
- `packages/features/ee/workflows/lib/reminders/emailReminderManager.ts` - Pass autoTranslateEnabled/sourceLocale
- `packages/features/ee/workflows/lib/scheduleBookingReminders.ts` - Pass fields to scheduleEmailReminder
- `packages/features/ee/workflows/lib/types.ts` - Added autoTranslateEnabled/sourceLocale to WorkflowStep type
- `packages/features/ee/workflows/lib/getAllWorkflows.ts` - Added fields to workflowSelect
- `packages/features/ee/workflows/lib/schema.ts` - Added fields to formSchema
- `packages/features/ee/workflows/lib/test/workflows.test.ts` - Added fields to test select
- `packages/features/ee/workflows/repositories/WorkflowReminderRepository.ts` - Added fields to findByIdIncludeStepAndWorkflow
- `packages/trpc/server/routers/viewer/workflows/update.schema.ts` - Added fields to step schema
- `packages/trpc/server/routers/viewer/workflows/update.handler.ts` - Org-only enforcement, trigger translation task
- `apps/web/modules/ee/workflows/components/WorkflowStepContainer.tsx` - UI toggle
- `apps/web/public/static/locales/en/common.json` - Translation strings
## Verification
- [ ] Run `yarn workspace @calcom/prisma db-migrate`
- [ ] Run `yarn prisma generate`
- [ ] Run `yarn type-check:ci --force`
+17
View File
@@ -0,0 +1,17 @@
# Workflow Translation Prompts
## Continue Feature
Continue working on workflow translation. Read specs/workflow-translation/implementation.md for current status.
## Sync Implementation Status
Review what's been implemented for workflow translation and update specs/workflow-translation/implementation.md
## Code Review
Review changes for: type safety, error handling, security, edge cases. Ensure it follows the event type translation pattern.
## Generate Tests
Write tests for workflow translation. Follow existing test patterns in packages/features/ee/workflows/.