diff --git a/apps/web/package.json b/apps/web/package.json index 1ca8761f2a..9fdac2e7b1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -64,7 +64,7 @@ "@radix-ui/react-switch": "^1.0.0", "@radix-ui/react-toggle-group": "^1.0.0", "@radix-ui/react-tooltip": "^1.0.0", - "@replexica/sdk": "^0.6.0", + "@replexica/sdk": "^0.7.0", "@sentry/nextjs": "^8.8.0", "@stripe/react-stripe-js": "^1.10.0", "@stripe/stripe-js": "^1.35.0", diff --git a/packages/features/bookings/Booker/components/EventMeta.tsx b/packages/features/bookings/Booker/components/EventMeta.tsx index 889ec090ac..ec5558b89d 100644 --- a/packages/features/bookings/Booker/components/EventMeta.tsx +++ b/packages/features/bookings/Booker/components/EventMeta.tsx @@ -1,4 +1,5 @@ import { m } from "framer-motion"; +import { useSession } from "next-auth/react"; import dynamic from "next/dynamic"; import { useEffect, useMemo } from "react"; import { shallow } from "zustand/shallow"; @@ -11,6 +12,8 @@ import { EventMetaBlock } from "@calcom/features/bookings/components/event-meta/ import { useTimePreferences } from "@calcom/features/bookings/lib"; import type { BookerEvent } from "@calcom/features/bookings/types"; import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { locales as i18nLocales } from "@calcom/lib/i18n"; +import { EventTypeAutoTranslatedField } from "@calcom/prisma/enums"; import { fadeInUp } from "../config"; import { useBookerStore } from "../store"; @@ -59,6 +62,7 @@ export const EventMeta = ({ eventMetaTimezoneSelect?: string; }; }) => { + const session = useSession(); const { setTimezone, timeFormat, timezone } = useTimePreferences(); const selectedDuration = useBookerStore((state) => state.selectedDuration); const selectedTimeslot = useBookerStore((state) => state.selectedTimeslot); @@ -103,9 +107,13 @@ export const EventMeta = ({ : isHalfFull ? "text-yellow-500" : "text-bookinghighlight"; - const browserLocale = navigator.language; // e.g. "en-US", "es-ES", "fr-FR" - const translatedDescription = (event?.fieldTranslations ?? []).find((translation) => - browserLocale.startsWith(translation.targetLang) + const userLocale = session.data?.user.locale ?? navigator.language; + const translatedDescription = (event?.fieldTranslations ?? []).find( + (trans) => + trans.field === EventTypeAutoTranslatedField.DESCRIPTION && + i18nLocales.includes(trans.targetLocale) && + // browser language looks like "en-US", "es-ES", "fr-FR", etc + (userLocale === trans.targetLocale || userLocale.split("-")[0] === trans.targetLocale) )?.translatedText; return ( diff --git a/packages/features/eventtypes/lib/getPublicEvent.ts b/packages/features/eventtypes/lib/getPublicEvent.ts index 1c4b2f9f78..14ecd04dd1 100644 --- a/packages/features/eventtypes/lib/getPublicEvent.ts +++ b/packages/features/eventtypes/lib/getPublicEvent.ts @@ -71,7 +71,8 @@ const publicEventSelect = Prisma.validator()({ fieldTranslations: { select: { translatedText: true, - targetLang: true, + targetLocale: true, + field: true, }, }, requiresBookerEmailVerification: true, diff --git a/packages/features/tasker/tasker.ts b/packages/features/tasker/tasker.ts index aa6e39f945..8cace546f3 100644 --- a/packages/features/tasker/tasker.ts +++ b/packages/features/tasker/tasker.ts @@ -14,6 +14,9 @@ type TaskPayloads = { triggerFormSubmittedNoEventWebhook: z.infer< typeof import("./tasks/triggerFormSubmittedNoEvent/triggerFormSubmittedNoEventWebhook").ZTriggerFormSubmittedNoEventWebhookPayloadSchema >; + translateEventTypeDescription: z.infer< + typeof import("./tasks/translateEventTypeDescription").ZTranslateEventTypeDescriptionPayloadSchema + >; }; export type TaskTypes = keyof TaskPayloads; export type TaskHandler = (payload: string) => Promise; diff --git a/packages/features/tasker/tasks/index.ts b/packages/features/tasker/tasks/index.ts index 2c1c152847..4e4183bc28 100644 --- a/packages/features/tasker/tasks/index.ts +++ b/packages/features/tasker/tasks/index.ts @@ -17,6 +17,8 @@ const tasks: Record Promise> = { (module) => module.triggerFormSubmittedNoEventWebhook ), sendSms: () => Promise.resolve(() => Promise.reject(new Error("Not implemented"))), + translateEventTypeDescription: () => + import("./translateEventTypeDescription").then((module) => module.translateEventTypeDescription), }; export default tasks; diff --git a/packages/features/tasker/tasks/translateEventTypeDescription.ts b/packages/features/tasker/tasks/translateEventTypeDescription.ts new file mode 100644 index 0000000000..e1c4a49220 --- /dev/null +++ b/packages/features/tasker/tasks/translateEventTypeDescription.ts @@ -0,0 +1,77 @@ +import { z } from "zod"; + +import { locales as i18nLocales } from "@calcom/lib/i18n"; +import logger from "@calcom/lib/logger"; +import { EventTypeTranslationRepository } from "@calcom/lib/server/repository/eventTypeTranslation"; + +export const ZTranslateEventTypeDescriptionPayloadSchema = z.object({ + eventTypeId: z.number(), + userId: z.number(), + description: z.string(), + 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 +] as const; + +export async function translateEventTypeDescription(payload: string): Promise { + const { eventTypeId, description, userLocale, userId } = ZTranslateEventTypeDescriptionPayloadSchema.parse( + JSON.parse(payload) + ); + + const targetLocales = SUPPORTED_LOCALES.filter( + (locale) => locale !== userLocale && i18nLocales.includes(locale) + ); + + const { ReplexicaService } = await import("@calcom/lib/server/service/replexica"); + try { + const translatedDescriptions = await Promise.all( + targetLocales.map((targetLocale) => + ReplexicaService.localizeText(description, userLocale, targetLocale) + ) + ); + + const validTranslations = translatedDescriptions + .map((translatedText, index) => ({ + translatedText, + targetLocale: targetLocales[index], + })) + .filter( + (item): item is { translatedText: string; targetLocale: (typeof SUPPORTED_LOCALES)[number] } => + item.translatedText !== null + ); + + if (validTranslations.length > 0) { + await EventTypeTranslationRepository.upsertManyDescriptionTranslations( + validTranslations.map(({ translatedText, targetLocale }) => ({ + eventTypeId, + sourceLocale: userLocale, + targetLocale, + // Keep old fields during transition + sourceLang: userLocale, // TODO: remove after migration + targetLang: targetLocale, // TODO: remove after migration + translatedText, + userId, + })) + ); + } + } catch (error) { + logger.error("Failed to process event type description translations:", error); + } +} diff --git a/packages/lib/server/repository/eventType.ts b/packages/lib/server/repository/eventType.ts index f240a419c3..b1ef591829 100644 --- a/packages/lib/server/repository/eventType.ts +++ b/packages/lib/server/repository/eventType.ts @@ -450,7 +450,8 @@ export class EventTypeRepository { fieldTranslations: { select: { translatedText: true, - targetLang: true, + targetLocale: true, + field: true, }, }, recurringEvent: true, diff --git a/packages/lib/server/repository/eventTypeTranslation.ts b/packages/lib/server/repository/eventTypeTranslation.ts new file mode 100644 index 0000000000..fac72c4faa --- /dev/null +++ b/packages/lib/server/repository/eventTypeTranslation.ts @@ -0,0 +1,53 @@ +import { prisma } from "@calcom/prisma"; +import type { EventTypeTranslation } from "@calcom/prisma/client"; +import { EventTypeAutoTranslatedField } from "@calcom/prisma/enums"; + +export type CreateEventTypeDescriptionTranslation = Omit< + EventTypeTranslation, + | "id" // TODO: remove after migration + | "uid" + | "createdAt" + | "createdBy" + | "updatedAt" + | "updatedBy" + | "eventType" + | "field" + | "creator" + | "updater" +> & { userId: number }; + +export class EventTypeTranslationRepository { + static async upsertManyDescriptionTranslations(translations: Array) { + return await Promise.all( + translations.map(({ userId, ...translation }) => { + const whereClause = translation.targetLang // TODO: remove after migration + ? { + eventTypeId_field_targetLang: { + eventTypeId: translation.eventTypeId, + field: EventTypeAutoTranslatedField.DESCRIPTION, + targetLang: translation.targetLang, + }, + } + : { + eventTypeId_field_targetLocale: { + eventTypeId: translation.eventTypeId, + field: EventTypeAutoTranslatedField.DESCRIPTION, + targetLocale: translation.targetLocale, + }, + }; + return prisma.eventTypeTranslation.upsert({ + where: whereClause, + update: { + translatedText: translation.translatedText, + updatedBy: userId, + }, + create: { + ...translation, + field: EventTypeAutoTranslatedField.DESCRIPTION, + createdBy: userId, + }, + }); + }) + ); + } +} diff --git a/packages/lib/server/service/replexica.ts b/packages/lib/server/service/replexica.ts index 9d0954b32e..b74ebfe0a1 100644 --- a/packages/lib/server/service/replexica.ts +++ b/packages/lib/server/service/replexica.ts @@ -1,6 +1,8 @@ import { ReplexicaEngine } from "@replexica/sdk"; +import type { LocaleCode } from "@replexica/spec"; import { REPLEXICA_API_KEY } from "@calcom/lib/constants"; +import logger from "@calcom/lib/logger"; export class ReplexicaService { private static engine = new ReplexicaEngine({ @@ -14,9 +16,13 @@ export class ReplexicaService { * @param targetLocale The target language locale * @returns The localized text */ - static async localizeText(text: string, sourceLocale: string, targetLocale: string): Promise { + static async localizeText( + text: string, + sourceLocale: string, + targetLocale: string + ): Promise { if (!text?.trim()) { - return text; + return null; } try { @@ -27,7 +33,33 @@ export class ReplexicaService { return result; } catch (error) { - return text; + logger.error(`ReplexicaService.localizeText() failed for targetLocale: ${targetLocale} - ${error}`); + return null; + } + } + + /** + * Localize a text string to multiple target locales + * @param text The text to localize + * @param sourceLocale The source language locale + * @param targetLocales Array of the target language locales + * @returns The localized texts + */ + static async batchLocalizeText( + text: string, + sourceLocale: string, + targetLocales: string[] + ): Promise { + try { + const result = await this.engine.batchLocalizeText(text, { + sourceLocale: sourceLocale as LocaleCode, + targetLocales: targetLocales as LocaleCode[], + }); + + return result; + } catch (error) { + logger.error(`ReplexicaService.batchLocalizeText() failed: ${error}`); + return []; } } @@ -54,6 +86,7 @@ export class ReplexicaService { return result.map((chat: { name: string; text: string }) => chat.text); } catch (error) { + logger.error(`ReplexicaService.localizeTexts() failed: ${error}`); return texts; } } diff --git a/packages/prisma/migrations/20241119132536_add_source_locale_and_target_locale/migration.sql b/packages/prisma/migrations/20241119132536_add_source_locale_and_target_locale/migration.sql new file mode 100644 index 0000000000..235371f29c --- /dev/null +++ b/packages/prisma/migrations/20241119132536_add_source_locale_and_target_locale/migration.sql @@ -0,0 +1,20 @@ +/* + Warnings: + + - Added the required column `sourceLocale` to the `EventTypeTranslation` table without a default value. This is not possible if the table is not empty. + - Added the required column `targetLocale` to the `EventTypeTranslation` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "EventTypeTranslation" ADD COLUMN "sourceLocale" TEXT, +ADD COLUMN "targetLocale" TEXT; + +-- Update data +UPDATE "EventTypeTranslation" SET + "sourceLocale" = "sourceLang", + "targetLocale" = "targetLang"; + +-- Then make NOT NULL after data is copied +ALTER TABLE "EventTypeTranslation" + ALTER COLUMN "sourceLocale" SET NOT NULL, + ALTER COLUMN "targetLocale" SET NOT NULL; \ No newline at end of file diff --git a/packages/prisma/migrations/20241120161007_update_event_type_translation/migration.sql b/packages/prisma/migrations/20241120161007_update_event_type_translation/migration.sql new file mode 100644 index 0000000000..129f8e65b6 --- /dev/null +++ b/packages/prisma/migrations/20241120161007_update_event_type_translation/migration.sql @@ -0,0 +1,24 @@ +/* + Warnings: + + - A unique constraint covering the columns `[eventTypeId,field,targetLocale]` on the table `EventTypeTranslation` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "EventTypeTranslation" ADD COLUMN "uid" TEXT; + +-- Copy data +UPDATE "EventTypeTranslation" SET + "uid" = "id"; + +-- CreateIndex +CREATE INDEX "EventTypeTranslation_eventTypeId_field_targetLocale_idx" ON "EventTypeTranslation"("eventTypeId", "field", "targetLocale"); + +-- CreateIndex +CREATE UNIQUE INDEX "EventTypeTranslation_eventTypeId_field_targetLocale_key" ON "EventTypeTranslation"("eventTypeId", "field", "targetLocale"); + +-- AddForeignKey +ALTER TABLE "EventTypeTranslation" ADD CONSTRAINT "EventTypeTranslation_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "EventTypeTranslation" ADD CONSTRAINT "EventTypeTranslation_updatedBy_fkey" FOREIGN KEY ("updatedBy") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 9074c06404..4b4bbc5c60 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -355,6 +355,8 @@ model User { referralLinkId String? features UserFeatures[] reassignedBookings Booking[] @relation("reassignByUser") + createdTranslations EventTypeTranslation[] @relation("CreatedEventTypeTranslations") + updatedTranslations EventTypeTranslation[] @relation("UpdatedEventTypeTranslations") @@unique([email]) @@unique([email, username]) @@ -1632,17 +1634,24 @@ enum EventTypeAutoTranslatedField { model EventTypeTranslation { id String @id @default(cuid()) + uid String? eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) eventTypeId Int field EventTypeAutoTranslatedField sourceLang String targetLang String + sourceLocale String + targetLocale String translatedText String @db.Text createdAt DateTime @default(now()) createdBy Int updatedAt DateTime @updatedAt updatedBy Int? + creator User @relation("CreatedEventTypeTranslations", fields: [createdBy], references: [id]) + updater User? @relation("UpdatedEventTypeTranslations", fields: [updatedBy], references: [id], onDelete: SetNull) - @@unique([eventTypeId, field, targetLang]) - @@index([eventTypeId, field, targetLang]) + @@unique([eventTypeId, field, targetLang]) // TODO: remove after migration + @@unique([eventTypeId, field, targetLocale]) + @@index([eventTypeId, field, targetLang]) // TODO: remove after migration + @@index([eventTypeId, field, targetLocale]) } diff --git a/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts index 819ac79b75..e5ba8d0c38 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts @@ -7,13 +7,14 @@ import { allowDisablingAttendeeConfirmationEmails, allowDisablingHostConfirmationEmails, } from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails"; +import tasker from "@calcom/features/tasker"; import { validateIntervalLimitOrder } from "@calcom/lib"; import logger from "@calcom/lib/logger"; import { getTranslation } from "@calcom/lib/server"; import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts"; import type { PrismaClient } from "@calcom/prisma"; import { WorkflowTriggerEvents } from "@calcom/prisma/client"; -import { SchedulingType } from "@calcom/prisma/enums"; +import { SchedulingType, EventTypeAutoTranslatedField } from "@calcom/prisma/enums"; import { TRPCError } from "@trpc/server"; @@ -36,6 +37,7 @@ type User = { }; selectedCalendars: SessionUser["selectedCalendars"]; organizationId: number | null; + locale: string; }; type UpdateOptions = { @@ -76,6 +78,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { aiPhoneCallConfig, isRRWeightsEnabled, autoTranslateDescriptionEnabled, + description: newDescription, ...rest } = input; @@ -83,6 +86,12 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { where: { id }, select: { title: true, + description: true, + fieldTranslations: { + select: { + field: true, + }, + }, isRRWeightsEnabled: true, hosts: { select: { @@ -153,10 +162,17 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { ensureUniqueBookingFields(bookingFields); ensureEmailOrPhoneNumberIsPresent(bookingFields); + if (autoTranslateDescriptionEnabled && !ctx.user.organizationId) { + logger.error( + "Auto-translating description requires an organization. This should not happen - UI controls should prevent this state." + ); + } + const data: Prisma.EventTypeUpdateInput = { ...rest, // autoTranslate feature is allowed for org users only autoTranslateDescriptionEnabled: !!(ctx.user.organizationId && autoTranslateDescriptionEnabled), + description: newDescription, bookingFields, isRRWeightsEnabled, rrSegmentQueryValue: @@ -480,6 +496,27 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { } } + // Logic for updating `fieldTranslations` + // user has no description translations OR user is changing the description + const descriptionTranslationsNeeded = + eventType.fieldTranslations.filter((trans) => trans.field === EventTypeAutoTranslatedField.DESCRIPTION) + .length === 0 || newDescription; + const description = newDescription ?? eventType.description; + + if ( + ctx.user.organizationId && + autoTranslateDescriptionEnabled && + descriptionTranslationsNeeded && + description + ) { + await tasker.create("translateEventTypeDescription", { + eventTypeId: id, + description, + userLocale: ctx.user.locale, + userId: ctx.user.id, + }); + } + const updatedEventTypeSelect = Prisma.validator()({ slug: true, schedulingType: true, diff --git a/yarn.lock b/yarn.lock index a488bf89cd..7e030d61b6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5035,7 +5035,7 @@ __metadata: "@radix-ui/react-switch": ^1.0.0 "@radix-ui/react-toggle-group": ^1.0.0 "@radix-ui/react-tooltip": ^1.0.0 - "@replexica/sdk": ^0.6.0 + "@replexica/sdk": ^0.7.0 "@sentry/nextjs": ^8.8.0 "@stripe/react-stripe-js": ^1.10.0 "@stripe/stripe-js": ^1.35.0 @@ -12316,28 +12316,28 @@ __metadata: languageName: node linkType: hard -"@replexica/sdk@npm:^0.6.0": - version: 0.6.0 - resolution: "@replexica/sdk@npm:0.6.0" +"@replexica/sdk@npm:^0.7.0": + version: 0.7.2 + resolution: "@replexica/sdk@npm:0.7.2" dependencies: "@paralleldrive/cuid2": ^2.2.2 - "@replexica/spec": 0.18.0 + "@replexica/spec": 0.19.0 jsdom: ^25.0.1 typescript: ^5.6.3 vitest: ^2.1.4 zod: ^3.23.8 - checksum: 95a239cf24025a8ea1a03b0720c8a1bd351f73777015fd3a8b74995740fb191c87d157ac04e7b1824f9b5b8f27cef3ce1b7126deb7bf7683b74b8435d3de1238 + checksum: 5199f4c7721eaefa66f9da062b11d62d79249afaa30098325d3e52550e2ac5fb2c3bdf550e61691e020023f11718a5240d38c8ac54726159e075991150620535 languageName: node linkType: hard -"@replexica/spec@npm:0.18.0": - version: 0.18.0 - resolution: "@replexica/spec@npm:0.18.0" +"@replexica/spec@npm:0.19.0": + version: 0.19.0 + resolution: "@replexica/spec@npm:0.19.0" dependencies: typescript: ^5.6.3 vitest: ^2.1.4 zod: ^3.23.8 - checksum: d9afcfd965b804d395436cc6daf9a051142e13658297e45f742a6397699360e8f28d43399c4c31077b250ca965e1733200bf8c63eae328b4960320d2dd6ae5b5 + checksum: 11114313e42c43ffe5c68e709eb465f4bbd0e24c70fcbdec5e56b4c2a9c4c10b5cd5899c253ffb652b1d66db13015f9ccbcf1d11479e079361c9d6bf8170ebf5 languageName: node linkType: hard