diff --git a/apps/web/cron-tester.ts b/apps/web/cron-tester.ts index a155ed9555..693ac7aafa 100644 --- a/apps/web/cron-tester.ts +++ b/apps/web/cron-tester.ts @@ -26,7 +26,7 @@ try { fetchCron("/calendar-cache/cron"), // fetchCron("/cron/calVideoNoShowWebhookTriggers"), // - // fetchCron("/tasks/cleanup"), + fetchCron("/tasks/cron"), ]); }, null, diff --git a/apps/web/playwright/managed-event-types.e2e.ts b/apps/web/playwright/managed-event-types.e2e.ts index 96d35bc47a..1085fcfa35 100644 --- a/apps/web/playwright/managed-event-types.e2e.ts +++ b/apps/web/playwright/managed-event-types.e2e.ts @@ -180,7 +180,7 @@ test.describe("Managed Event Types", () => { }); const MANAGED_EVENT_TABS: { slug: string; locator: (page: Page) => Locator | Promise }[] = [ - { slug: "setup", locator: (page) => getByKey(page, "title") }, + { slug: "setup", locator: (page) => getByKey(page, "translate_description_button") }, { slug: "team", locator: (page) => getByKey(page, "automatically_add_all_team_members"), diff --git a/apps/web/playwright/webhook.e2e.ts b/apps/web/playwright/webhook.e2e.ts index 01376987fb..8ae9fdecce 100644 --- a/apps/web/playwright/webhook.e2e.ts +++ b/apps/web/playwright/webhook.e2e.ts @@ -658,12 +658,7 @@ test.describe("FORM_SUBMITTED", async () => { webhookReceiver.close(); }); - test("on submitting team form, triggers team webhook @test", async ({ - page, - users, - routingForms, - webhooks, - }) => { + test("on submitting team form, triggers team webhook", async ({ page, users, routingForms, webhooks }) => { const user = await users.create(null, { hasTeam: true, }); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 4c7119fe76..10275cdb14 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2845,7 +2845,7 @@ "filter_operator_ends_with": "Ends with", "filter_operator_is_empty": "Is empty", "filter_operator_not_empty": "Not empty", - "translate_description_button": "Translate description to the visitor's browser language using AI", + "translate_description_button": "Translate title/description to the visitor's browser language using AI", "rr_distribution_method": "Distribution", "rr_distribution_method_description": "Allows for optimising distribution for maximum availability or to aim for a more balanced assignment.", "rr_distribution_method_availability_title": "Maximize availability", diff --git a/apps/web/test/lib/generateCsv.test.ts b/apps/web/test/lib/generateCsv.test.ts index dae38a4f1a..fe4967efb4 100644 --- a/apps/web/test/lib/generateCsv.test.ts +++ b/apps/web/test/lib/generateCsv.test.ts @@ -52,7 +52,7 @@ function createMockTable(data: UserTableUser[]): Table { } as unknown as Table; } -describe("generate Csv for Org Users Table @test", () => { +describe("generate Csv for Org Users Table", () => { const orgDomain = "https://acme.cal.com"; const mockAttributeIds = ["attr1", "attr2"]; const mockUser: UserTableUser = { diff --git a/packages/features/bookings/Booker/components/EventMeta.tsx b/packages/features/bookings/Booker/components/EventMeta.tsx index 10b05cf711..c5140ac586 100644 --- a/packages/features/bookings/Booker/components/EventMeta.tsx +++ b/packages/features/bookings/Booker/components/EventMeta.tsx @@ -12,6 +12,7 @@ import { useTimePreferences } from "@calcom/features/bookings/lib"; import type { BookerEvent } from "@calcom/features/bookings/types"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML"; +import type { EventTypeTranslation } from "@calcom/prisma/client"; import { EventTypeAutoTranslatedField } from "@calcom/prisma/enums"; import i18nConfigration from "../../../../../i18n.json"; @@ -26,6 +27,21 @@ const WebTimezoneSelect = dynamic( } ); +const getTranslatedField = ( + translations: Array>, + field: EventTypeAutoTranslatedField, + userLocale: string +) => { + const i18nLocales = i18nConfigration.locale.targets.concat([i18nConfigration.locale.source]); + + return translations?.find( + (trans) => + trans.field === field && + i18nLocales.includes(trans.targetLocale) && + (userLocale === trans.targetLocale || userLocale.split("-")[0] === trans.targetLocale) + )?.translatedText; +}; + export const EventMeta = ({ event, isPending, @@ -82,7 +98,6 @@ export const EventMeta = ({ () => (isPlatform ? [PlatformTimezoneSelect] : [WebTimezoneSelect]), [isPlatform] ); - const i18nLocales = i18nConfigration.locale.targets.concat([i18nConfigration.locale.source]); useEffect(() => { //In case the event has lockTimeZone enabled ,set the timezone to event's attached availability timezone @@ -110,13 +125,16 @@ export const EventMeta = ({ ? "text-yellow-500" : "text-bookinghighlight"; const userLocale = 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; + const translatedDescription = getTranslatedField( + event?.fieldTranslations ?? [], + EventTypeAutoTranslatedField.DESCRIPTION, + userLocale + ); + const translatedTitle = getTranslatedField( + event?.fieldTranslations ?? [], + EventTypeAutoTranslatedField.TITLE, + userLocale + ); return (
@@ -133,7 +151,9 @@ export const EventMeta = ({ profile={event.profile} entity={event.entity} /> - {event?.title} + + {translatedTitle ?? event?.title} + {(event.description || translatedDescription) && (
; - translateEventTypeDescription: z.infer< - typeof import("./tasks/translateEventTypeDescription").ZTranslateEventTypeDescriptionPayloadSchema + translateEventTypeData: z.infer< + typeof import("./tasks/translateEventTypeData").ZTranslateEventDataPayloadSchema >; }; export type TaskTypes = keyof TaskPayloads; diff --git a/packages/features/tasker/tasks/index.ts b/packages/features/tasker/tasks/index.ts index 4e4183bc28..e8adead8dc 100644 --- a/packages/features/tasker/tasks/index.ts +++ b/packages/features/tasker/tasks/index.ts @@ -17,8 +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), + translateEventTypeData: () => + import("./translateEventTypeData").then((module) => module.translateEventTypeData), }; export default tasks; diff --git a/packages/features/tasker/tasks/translateEventTypeData.ts b/packages/features/tasker/tasks/translateEventTypeData.ts new file mode 100644 index 0000000000..ab444a98e5 --- /dev/null +++ b/packages/features/tasker/tasks/translateEventTypeData.ts @@ -0,0 +1,108 @@ +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"; +import { EventTypeAutoTranslatedField } from "@calcom/prisma/enums"; + +export const ZTranslateEventDataPayloadSchema = z.object({ + eventTypeId: z.number(), + userId: z.number(), + description: z.string().nullable().optional(), + title: z.string().optional(), + 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; + +async function processTranslations({ + text, + userLocale, + eventTypeId, + userId, + field, +}: { + text: string; + field: EventTypeAutoTranslatedField; +} & z.infer) { + const { ReplexicaService } = await import("@calcom/lib/server/service/replexica"); + + try { + const targetLocales = SUPPORTED_LOCALES.filter( + (locale) => locale !== userLocale && i18nLocales.includes(locale) + ); + + const translations = await Promise.all( + targetLocales.map((targetLocale) => ReplexicaService.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 }) => ({ + eventTypeId, + sourceLocale: userLocale, + targetLocale, + translatedText, + userId, + })); + + const upsertMany = + field === EventTypeAutoTranslatedField.DESCRIPTION + ? EventTypeTranslationRepository.upsertManyDescriptionTranslations + : EventTypeTranslationRepository.upsertManyTitleTranslations; + + await upsertMany(translationData); + } + } catch (error) { + logger.error(`Failed to process ${field} translations:`, error); + } +} + +export async function translateEventTypeData(payload: string): Promise { + const { eventTypeId, description, title, userLocale, userId } = ZTranslateEventDataPayloadSchema.parse( + JSON.parse(payload) + ); + + await Promise.all([ + description && + processTranslations({ + text: description, + userLocale, + eventTypeId, + userId, + field: EventTypeAutoTranslatedField.DESCRIPTION, + }), + title && + processTranslations({ + text: title, + userLocale, + eventTypeId, + userId, + field: EventTypeAutoTranslatedField.TITLE, + }), + ]); +} diff --git a/packages/features/tasker/tasks/translateEventTypeDescription.ts b/packages/features/tasker/tasks/translateEventTypeDescription.ts deleted file mode 100644 index 900d1d437e..0000000000 --- a/packages/features/tasker/tasks/translateEventTypeDescription.ts +++ /dev/null @@ -1,74 +0,0 @@ -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, - translatedText, - userId, - })) - ); - } - } catch (error) { - logger.error("Failed to process event type description translations:", error); - } -} diff --git a/packages/lib/server/repository/eventTypeTranslation.ts b/packages/lib/server/repository/eventTypeTranslation.ts index c619bda00b..ffb9c21a30 100644 --- a/packages/lib/server/repository/eventTypeTranslation.ts +++ b/packages/lib/server/repository/eventTypeTranslation.ts @@ -2,7 +2,7 @@ import { prisma } from "@calcom/prisma"; import type { EventTypeTranslation } from "@calcom/prisma/client"; import { EventTypeAutoTranslatedField } from "@calcom/prisma/enums"; -export type CreateEventTypeDescriptionTranslation = Omit< +export type CreateEventTypeTranslation = Omit< EventTypeTranslation, | "uid" | "createdAt" @@ -16,7 +16,32 @@ export type CreateEventTypeDescriptionTranslation = Omit< > & { userId: number }; export class EventTypeTranslationRepository { - static async upsertManyDescriptionTranslations(translations: Array) { + static async upsertManyTitleTranslations(translations: Array) { + return await Promise.all( + translations.map(({ userId, ...translation }) => { + return prisma.eventTypeTranslation.upsert({ + where: { + eventTypeId_field_targetLocale: { + eventTypeId: translation.eventTypeId, + field: EventTypeAutoTranslatedField.TITLE, + targetLocale: translation.targetLocale, + }, + }, + update: { + translatedText: translation.translatedText, + updatedBy: userId, + }, + create: { + ...translation, + field: EventTypeAutoTranslatedField.TITLE, + createdBy: userId, + }, + }); + }) + ); + } + + static async upsertManyDescriptionTranslations(translations: Array) { return await Promise.all( translations.map(({ userId, ...translation }) => { return prisma.eventTypeTranslation.upsert({ diff --git a/packages/prisma/migrations/20241122050747_add_title_to_event_type_auto_translated_field/migration.sql b/packages/prisma/migrations/20241122050747_add_title_to_event_type_auto_translated_field/migration.sql new file mode 100644 index 0000000000..b44eb9aa79 --- /dev/null +++ b/packages/prisma/migrations/20241122050747_add_title_to_event_type_auto_translated_field/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "EventTypeAutoTranslatedField" ADD VALUE 'TITLE'; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index cf472ef5a6..fffac5ce4e 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -1669,7 +1669,8 @@ model AssignmentReason { } enum EventTypeAutoTranslatedField { - DESCRIPTION // Currently the only field we translate + DESCRIPTION + TITLE } model DomainWideDelegation { diff --git a/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts index e5ba8d0c38..0cb80b95e3 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts @@ -79,6 +79,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { isRRWeightsEnabled, autoTranslateDescriptionEnabled, description: newDescription, + title: newTitle, ...rest } = input; @@ -173,6 +174,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { // autoTranslate feature is allowed for org users only autoTranslateDescriptionEnabled: !!(ctx.user.organizationId && autoTranslateDescriptionEnabled), description: newDescription, + title: newTitle, bookingFields, isRRWeightsEnabled, rrSegmentQueryValue: @@ -497,21 +499,21 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { } // Logic for updating `fieldTranslations` - // user has no description translations OR user is changing the description - const descriptionTranslationsNeeded = + // user has no translations OR user is changing the field + const hasNoDescriptionTranslations = eventType.fieldTranslations.filter((trans) => trans.field === EventTypeAutoTranslatedField.DESCRIPTION) - .length === 0 || newDescription; - const description = newDescription ?? eventType.description; + .length === 0; + const description = newDescription ?? (hasNoDescriptionTranslations ? eventType.description : undefined); + const hasNoTitleTranslations = + eventType.fieldTranslations.filter((trans) => trans.field === EventTypeAutoTranslatedField.TITLE) + .length === 0; + const title = newTitle ?? (hasNoTitleTranslations ? eventType.title : undefined); - if ( - ctx.user.organizationId && - autoTranslateDescriptionEnabled && - descriptionTranslationsNeeded && - description - ) { - await tasker.create("translateEventTypeDescription", { + if (ctx.user.organizationId && autoTranslateDescriptionEnabled && (title || description)) { + await tasker.create("translateEventTypeData", { eventTypeId: id, description, + title, userLocale: ctx.user.locale, userId: ctx.user.id, });