feat: Title - AI translation for booking pages (#17794)
* feat: AI description - DB model + frontend + backend (fetch only) * fix types and add validation to backend * improve log * improve * import type * add EventTypeTranslationRepository * fix replexica error * fix replexica error * fix * fix test * wip * wip * add createManyDescriptionTranslations method * improve logic * refactor * update replexica type * improve typing * Renamed descriptionTranslations to fieldTranslations * log errors in replexica service * handle translations in background * don't create, upsert instead * make sure that description is updated * simplify * fix * Update packages/trpc/server/routers/viewer/eventTypes/update.handler.ts * improve filter logic in frontend * implement background job using Tasker * update schema * update schema again * rename * rename id to uid * add migration * fix type * update * update schema and migration sql * fix migration * fix type check * fix type check 2 * improve find logic in EventMeta * add more validation * add logger * rename * upgrade replexica/sdk to 0.7.0 * upgrade replexica to 0.7.0 * use batchLocalizeText * add comments * override browser locale if user is a signed in calcom user * fix type in replexica * fix migration sql * fix type * use dynamic import * do not use batchLocalizeText * chore: drop sourceLang, targetLang and id columns * fix * wip * add more validations * fix type error * address comment * refactor * update booking page to use translated title * fix type error * fix * revert yarn.lock changes * remove unused import * remove duplication migrate.sql file * fix test * remove test tags * fix function name * refactor * fix type check * use Promise.all * remove unneeded code --------- Co-authored-by: Keith Williams <keithwillcode@gmail.com>
This commit is contained in:
co-authored by
Keith Williams
parent
dd3505ad8b
commit
6739ed3a87
@@ -26,7 +26,7 @@ try {
|
||||
fetchCron("/calendar-cache/cron"),
|
||||
// fetchCron("/cron/calVideoNoShowWebhookTriggers"),
|
||||
//
|
||||
// fetchCron("/tasks/cleanup"),
|
||||
fetchCron("/tasks/cron"),
|
||||
]);
|
||||
},
|
||||
null,
|
||||
|
||||
@@ -180,7 +180,7 @@ test.describe("Managed Event Types", () => {
|
||||
});
|
||||
|
||||
const MANAGED_EVENT_TABS: { slug: string; locator: (page: Page) => Locator | Promise<Locator> }[] = [
|
||||
{ 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"),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -52,7 +52,7 @@ function createMockTable(data: UserTableUser[]): Table<UserTableUser> {
|
||||
} as unknown as Table<UserTableUser>;
|
||||
}
|
||||
|
||||
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 = {
|
||||
|
||||
@@ -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<Pick<EventTypeTranslation, "field" | "targetLocale" | "translatedText">>,
|
||||
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 (
|
||||
<div className={`${classNames?.eventMetaContainer || ""} relative z-10 p-6`} data-testid="event-meta">
|
||||
@@ -133,7 +151,9 @@ export const EventMeta = ({
|
||||
profile={event.profile}
|
||||
entity={event.entity}
|
||||
/>
|
||||
<EventTitle className={`${classNames?.eventMetaTitle} my-2`}>{event?.title}</EventTitle>
|
||||
<EventTitle className={`${classNames?.eventMetaTitle} my-2`}>
|
||||
{translatedTitle ?? event?.title}
|
||||
</EventTitle>
|
||||
{(event.description || translatedDescription) && (
|
||||
<EventMetaBlock contentClassName="mb-8 break-words max-w-full max-h-[180px] scroll-bar pr-4">
|
||||
<div
|
||||
|
||||
@@ -14,8 +14,8 @@ type TaskPayloads = {
|
||||
triggerFormSubmittedNoEventWebhook: z.infer<
|
||||
typeof import("./tasks/triggerFormSubmittedNoEvent/triggerFormSubmittedNoEventWebhook").ZTriggerFormSubmittedNoEventWebhookPayloadSchema
|
||||
>;
|
||||
translateEventTypeDescription: z.infer<
|
||||
typeof import("./tasks/translateEventTypeDescription").ZTranslateEventTypeDescriptionPayloadSchema
|
||||
translateEventTypeData: z.infer<
|
||||
typeof import("./tasks/translateEventTypeData").ZTranslateEventDataPayloadSchema
|
||||
>;
|
||||
};
|
||||
export type TaskTypes = keyof TaskPayloads;
|
||||
|
||||
@@ -17,8 +17,8 @@ const tasks: Record<TaskTypes, () => Promise<TaskHandler>> = {
|
||||
(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;
|
||||
|
||||
@@ -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<typeof ZTranslateEventDataPayloadSchema>) {
|
||||
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<void> {
|
||||
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,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
@@ -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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<CreateEventTypeDescriptionTranslation>) {
|
||||
static async upsertManyTitleTranslations(translations: Array<CreateEventTypeTranslation>) {
|
||||
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<CreateEventTypeTranslation>) {
|
||||
return await Promise.all(
|
||||
translations.map(({ userId, ...translation }) => {
|
||||
return prisma.eventTypeTranslation.upsert({
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "EventTypeAutoTranslatedField" ADD VALUE 'TITLE';
|
||||
@@ -1669,7 +1669,8 @@ model AssignmentReason {
|
||||
}
|
||||
|
||||
enum EventTypeAutoTranslatedField {
|
||||
DESCRIPTION // Currently the only field we translate
|
||||
DESCRIPTION
|
||||
TITLE
|
||||
}
|
||||
|
||||
model DomainWideDelegation {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user