From 4081d11fbec98a4267f6946877cc138a1a24505b Mon Sep 17 00:00:00 2001 From: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Date: Wed, 25 Feb 2026 01:03:55 +0530 Subject: [PATCH] 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 * 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 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 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 Co-authored-by: Udit Takkar --- .../controllers/slots.controller.e2e-spec.ts | 10 +- ...er-event-type-slots.controller.e2e-spec.ts | 21 +- .../modules/workflows/workflows.repository.ts | 12 +- .../components/WorkflowDetailsPage.tsx | 2 + .../components/WorkflowStepContainer.tsx | 98 ++++-- .../di/containers/TranslationService.ts | 10 + .../features/di/modules/TranslationService.ts | 43 +++ packages/features/di/tokens.ts | 2 + ...flowStepTranslationRepository.container.ts | 15 + ...orkflowStepTranslationRepository.module.ts | 23 ++ .../ee/workflows/lib/getAllWorkflows.ts | 2 + .../lib/reminders/emailReminderManager.ts | 7 + .../lib/reminders/reminderScheduler.ts | 15 +- .../lib/reminders/smsReminderManager.ts | 32 +- .../ee/workflows/lib/reminders/utils.ts | 6 +- .../lib/reminders/whatsappReminderManager.ts | 12 +- .../workflows/lib/scheduleBookingReminders.ts | 6 + packages/features/ee/workflows/lib/schema.ts | 2 + .../lib/service/EmailWorkflowService.test.ts | 138 +++++++- .../lib/service/EmailWorkflowService.ts | 59 +++- .../ee/workflows/lib/test/workflows.test.ts | 2 + packages/features/ee/workflows/lib/types.ts | 2 + .../WorkflowReminderRepository.ts | 2 + .../repositories/WorkflowStepRepository.ts | 11 + .../WorkflowStepTranslationRepository.test.ts | 117 +++++++ .../WorkflowStepTranslationRepository.ts | 79 +++++ ...ventTypeTranslationRepository.container.ts | 15 + .../EventTypeTranslationRepository.module.ts | 23 ++ .../EventTypeTranslationRepository.test.ts | 140 ++++++++ .../EventTypeTranslationRepository.ts | 25 +- packages/features/tasker/tasker.ts | 3 + packages/features/tasker/tasks/index.ts | 2 + .../tasker/tasks/translateEventTypeData.ts | 71 ++-- .../tasks/translateWorkflowStepData.test.ts | 324 ++++++++++++++++++ .../tasker/tasks/translateWorkflowStepData.ts | 98 ++++++ packages/features/translation/di/tokens.ts | 8 + .../services/ITranslationService.ts | 51 +++ .../services/TranslationService.test.ts | 263 ++++++++++++++ .../services/TranslationService.ts | 144 ++++++++ packages/i18n/locales/en/common.json | 3 + packages/lib/translationConstants.ts | 27 ++ .../migration.sql | 26 ++ packages/prisma/schema.prisma | 25 +- .../viewer/workflows/update.handler.ts | 97 +++++- .../routers/viewer/workflows/update.schema.ts | 2 + specs/workflow-translation/CLAUDE.md | 40 +++ specs/workflow-translation/decisions.md | 42 +++ specs/workflow-translation/design.md | 102 ++++++ specs/workflow-translation/docs/README.md | 39 +++ .../docs/screenshots/.gitkeep | 0 specs/workflow-translation/future-work.md | 16 + specs/workflow-translation/implementation.md | 59 ++++ specs/workflow-translation/prompts.md | 17 + 53 files changed, 2241 insertions(+), 149 deletions(-) create mode 100644 packages/features/di/containers/TranslationService.ts create mode 100644 packages/features/di/modules/TranslationService.ts create mode 100644 packages/features/ee/workflows/di/WorkflowStepTranslationRepository.container.ts create mode 100644 packages/features/ee/workflows/di/WorkflowStepTranslationRepository.module.ts create mode 100644 packages/features/ee/workflows/repositories/WorkflowStepTranslationRepository.test.ts create mode 100644 packages/features/ee/workflows/repositories/WorkflowStepTranslationRepository.ts create mode 100644 packages/features/eventTypeTranslation/di/EventTypeTranslationRepository.container.ts create mode 100644 packages/features/eventTypeTranslation/di/EventTypeTranslationRepository.module.ts create mode 100644 packages/features/eventTypeTranslation/repositories/EventTypeTranslationRepository.test.ts create mode 100644 packages/features/tasker/tasks/translateWorkflowStepData.test.ts create mode 100644 packages/features/tasker/tasks/translateWorkflowStepData.ts create mode 100644 packages/features/translation/di/tokens.ts create mode 100644 packages/features/translation/services/ITranslationService.ts create mode 100644 packages/features/translation/services/TranslationService.test.ts create mode 100644 packages/features/translation/services/TranslationService.ts create mode 100644 packages/lib/translationConstants.ts create mode 100644 packages/prisma/migrations/20260121145242_add_workflow_step_translation/migration.sql create mode 100644 specs/workflow-translation/CLAUDE.md create mode 100644 specs/workflow-translation/decisions.md create mode 100644 specs/workflow-translation/design.md create mode 100644 specs/workflow-translation/docs/README.md create mode 100644 specs/workflow-translation/docs/screenshots/.gitkeep create mode 100644 specs/workflow-translation/future-work.md create mode 100644 specs/workflow-translation/implementation.md create mode 100644 specs/workflow-translation/prompts.md diff --git a/apps/api/v2/src/modules/slots/slots-2024-04-15/controllers/slots.controller.e2e-spec.ts b/apps/api/v2/src/modules/slots/slots-2024-04-15/controllers/slots.controller.e2e-spec.ts index f452d47cae..3a7283f982 100644 --- a/apps/api/v2/src/modules/slots/slots-2024-04-15/controllers/slots.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/slots/slots-2024-04-15/controllers/slots.controller.e2e-spec.ts @@ -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: { diff --git a/apps/api/v2/src/modules/slots/slots-2024-09-04/controllers/e2e/user-event-type-slots.controller.e2e-spec.ts b/apps/api/v2/src/modules/slots/slots-2024-09-04/controllers/e2e/user-event-type-slots.controller.e2e-spec.ts index 0bf0f370f7..3e9d3c99cc 100644 --- a/apps/api/v2/src/modules/slots/slots-2024-09-04/controllers/e2e/user-event-type-slots.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/slots/slots-2024-09-04/controllers/e2e/user-event-type-slots.controller.e2e-spec.ts @@ -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(); diff --git a/apps/api/v2/src/modules/workflows/workflows.repository.ts b/apps/api/v2/src/modules/workflows/workflows.repository.ts index f8be74f9df..4a341eb32d 100644 --- a/apps/api/v2/src/modules/workflows/workflows.repository.ts +++ b/apps/api/v2/src/modules/workflows/workflows.repository.ts @@ -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, diff --git a/apps/web/modules/ee/workflows/components/WorkflowDetailsPage.tsx b/apps/web/modules/ee/workflows/components/WorkflowDetailsPage.tsx index f564e5490a..8c40078885 100644 --- a/apps/web/modules/ee/workflows/components/WorkflowDetailsPage.tsx +++ b/apps/web/modules/ee/workflows/components/WorkflowDetailsPage.tsx @@ -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); diff --git a/apps/web/modules/ee/workflows/components/WorkflowStepContainer.tsx b/apps/web/modules/ee/workflows/components/WorkflowStepContainer.tsx index 150b7a89fc..cbd1bbbf41 100644 --- a/apps/web/modules/ee/workflows/components/WorkflowStepContainer.tsx +++ b/apps/web/modules/ee/workflows/components/WorkflowStepContainer.tsx @@ -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) { /> )} + {(step.action === WorkflowActions.EMAIL_ATTENDEE || + step.action === WorkflowActions.SMS_ATTENDEE) && ( +
+
+ ( + + form.setValue( + `steps.${step.stepNumber - 1}.autoTranslateEnabled`, + e.target.checked, + { shouldDirty: true } + ) + } + /> + )} + /> + {!props.user.organizationId && ( + + {t("upgrade_to_organizations")} + + )} +
+ {props.user.organizationId && + form.watch(`steps.${step.stepNumber - 1}.autoTranslateEnabled`) && ( +

+ {t("auto_translate_source_language_hint", { + language: new Intl.DisplayNames([i18n.language], { type: "language" }).of( + props.user.locale || "en" + ), + })} +

+ )} +
+ )} {!props.readOnly && (