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:
53 files changed
+2241
-149
No files matched your search
+5
-5
@@ -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: {
|
||||
|
||||
+11
-10
@@ -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 };
|
||||
@@ -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 },
|
||||
|
||||
+117
@@ -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 };
|
||||
+140
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+19
-6
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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.
@@ -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`
|
||||
@@ -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/.
|
||||
Reference in new issue
Block a user