From 3a59cd41cc802bbade32b342f0a59868f140ba7e Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Date: Tue, 9 Dec 2025 08:51:51 -0500 Subject: [PATCH] feat: add toggle to opt out of booking title translation in instant meetings (#25547) * feat: add toggle to opt out of booking title translation in instant meetings Co-Authored-By: joe@cal.com * fix: add autoTranslateTitleEnabled to test builder Co-Authored-By: joe@cal.com * fix: respect autoTranslateTitleEnabled toggle in InstantBookingCreateService Co-Authored-By: joe@cal.com * fix: fetch autoTranslateTitleEnabled directly in InstantBookingCreateService Co-Authored-By: joe@cal.com * refactor: rename autoTranslateTitleEnabled to autoTranslateInstantMeetingTitleEnabled - Renamed field to clarify it only applies to instant meeting title translation - Moved Prisma query to EventTypeRepository.findInstantMeetingConfigById() - Removed title translation logic from update.handler.ts (flag only controls instant meeting title) - Updated all references across the codebase - Added new i18n translation keys for the renamed field Co-Authored-By: joe@cal.com * fix: add autoTranslateInstantMeetingTitleEnabled to test destructuring patterns Co-Authored-By: joe@cal.com * fix: use Prisma.TeamCreateInput type for metadata in test helper Co-Authored-By: joe@cal.com * fix: add autoTranslateInstantMeetingTitleEnabled to mockUpdatedEventType in test Co-Authored-By: joe@cal.com * refactor: use generic findByIdMinimal instead of business-specific method - Add autoTranslateInstantMeetingTitleEnabled to eventTypeSelect constant - Remove findInstantMeetingConfigById from EventTypeRepository - Update InstantBookingCreateService to use findByIdMinimal Co-Authored-By: joe@cal.com * refactor: remove autoTranslateInstantMeetingTitleEnabled from eventTypeSelect (not needed for findByIdMinimal) Co-Authored-By: joe@cal.com * feat: add autoTranslateInstantMeetingTitleEnabled to event type form defaults - Rename shouldTranslateTitle to shouldAutoTranslateInstantMeetingTitle for clarity - Add autoTranslateInstantMeetingTitleEnabled to findById and findByIdForOrgAdmin selects - Add autoTranslateInstantMeetingTitleEnabled to useEventTypeForm defaultValues Co-Authored-By: joe@cal.com * fix: only update autoTranslateInstantMeetingTitleEnabled when explicitly provided Fixes issue where omitting the field in update requests would overwrite the saved opt-out setting with the default value (true). Now the field is only included in the update payload when explicitly provided. Co-Authored-By: joe@cal.com --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/web/public/static/locales/en/common.json | 6 ++++- .../test/lib/handleChildrenEventTypes.test.ts | 9 +++++++ .../handleNewBooking/getEventTypesFromDB.ts | 2 ++ .../service/InstantBookingCreateService.ts | 24 +++++++++---------- .../tabs/instant/InstantEventController.tsx | 15 ++++++++++++ .../features/eventtypes/lib/defaultEvents.ts | 1 + packages/features/eventtypes/lib/types.ts | 1 + .../repositories/eventTypeRepository.ts | 2 ++ packages/lib/test/builder.ts | 1 + .../event-types/hooks/useEventTypeForm.ts | 1 + .../migration.sql | 2 ++ packages/prisma/schema.prisma | 1 + .../viewer/eventTypes/heavy/update.handler.ts | 4 +++- .../inviteMember.handler.integration-test.ts | 5 ++-- 14 files changed, 57 insertions(+), 17 deletions(-) create mode 100644 packages/prisma/migrations/20251202143411_add_auto_translate_title_enabled/migration.sql diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 3fb28a5840..d89bd84150 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -3261,7 +3261,11 @@ "filter_operator_is_empty": "Is empty", "filter_operator_not_empty": "Not empty", "translate_description_button": "Auto translate title and description", - "translate_description_button_description": "Automatically translate titles and descriptions to the visitor’s browser language using AI.", + "translate_description_button_description": "Automatically translate titles and descriptions to the visitor's browser language using AI.", + "auto_translate_booking_title": "Auto translate booking title", + "auto_translate_booking_title_description": "Automatically translate the booking title to the visitor's browser language. Disable this to keep the original title.", + "auto_translate_instant_meeting_title": "Auto translate instant meeting title", + "auto_translate_instant_meeting_title_description": "Automatically translate the instant meeting booking title to the visitor's browser language. Disable this to keep the original English title.", "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/handleChildrenEventTypes.test.ts b/apps/web/test/lib/handleChildrenEventTypes.test.ts index 30be95e0d3..57f596d3fa 100644 --- a/apps/web/test/lib/handleChildrenEventTypes.test.ts +++ b/apps/web/test/lib/handleChildrenEventTypes.test.ts @@ -136,6 +136,7 @@ describe("handleChildrenEventTypes", () => { useEventTypeDestinationCalendarEmail, secondaryEmailId, autoTranslateDescriptionEnabled, + autoTranslateInstantMeetingTitleEnabled, includeNoShowInRRCalculation, instantMeetingScheduleId, ...evType @@ -161,6 +162,7 @@ describe("handleChildrenEventTypes", () => { useEventTypeDestinationCalendarEmail, secondaryEmailId, autoTranslateDescriptionEnabled, + autoTranslateInstantMeetingTitleEnabled, includeNoShowInRRCalculation, instantMeetingScheduleId, }; @@ -223,6 +225,7 @@ describe("handleChildrenEventTypes", () => { useEventTypeDestinationCalendarEmail, secondaryEmailId, assignRRMembersUsingSegment, + autoTranslateInstantMeetingTitleEnabled, includeNoShowInRRCalculation, instantMeetingScheduleId, ...evType @@ -332,6 +335,7 @@ describe("handleChildrenEventTypes", () => { useEventTypeDestinationCalendarEmail, secondaryEmailId, autoTranslateDescriptionEnabled, + autoTranslateInstantMeetingTitleEnabled, includeNoShowInRRCalculation, instantMeetingScheduleId, assignRRMembersUsingSegment, @@ -358,6 +362,7 @@ describe("handleChildrenEventTypes", () => { useEventTypeDestinationCalendarEmail, secondaryEmailId, autoTranslateDescriptionEnabled, + autoTranslateInstantMeetingTitleEnabled, includeNoShowInRRCalculation, instantMeetingScheduleId, assignRRMembersUsingSegment, @@ -420,6 +425,7 @@ describe("handleChildrenEventTypes", () => { lockTimeZoneToggleOnBookingPage, useEventTypeDestinationCalendarEmail, secondaryEmailId, + autoTranslateInstantMeetingTitleEnabled, includeNoShowInRRCalculation, instantMeetingScheduleId, assignRRMembersUsingSegment, @@ -487,6 +493,7 @@ describe("handleChildrenEventTypes", () => { useEventTypeDestinationCalendarEmail, secondaryEmailId, autoTranslateDescriptionEnabled, + autoTranslateInstantMeetingTitleEnabled, includeNoShowInRRCalculation, instantMeetingScheduleId, assignRRMembersUsingSegment, @@ -520,6 +527,7 @@ describe("handleChildrenEventTypes", () => { useEventTypeDestinationCalendarEmail, secondaryEmailId, autoTranslateDescriptionEnabled, + autoTranslateInstantMeetingTitleEnabled, includeNoShowInRRCalculation, instantMeetingScheduleId, assignRRMembersUsingSegment, @@ -533,6 +541,7 @@ describe("handleChildrenEventTypes", () => { timeZone: "UTC", teamId: 1, autoTranslateDescriptionEnabled: false, + autoTranslateInstantMeetingTitleEnabled: true, secondaryEmailId: null, schedulingType: SchedulingType.MANAGED, requiresBookerEmailVerification: false, diff --git a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts index b7646ce77f..f0f07a8c41 100644 --- a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts +++ b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts @@ -181,6 +181,8 @@ const getEventTypesFromDBSelect = { name: true, }, }, + instantMeetingExpiryTimeOffsetInSeconds: true, + autoTranslateInstantMeetingTitleEnabled: true, } satisfies Prisma.EventTypeSelect; export const getEventTypesFromDB = async (eventTypeId: number) => { diff --git a/packages/features/bookings/lib/service/InstantBookingCreateService.ts b/packages/features/bookings/lib/service/InstantBookingCreateService.ts index b6a5260ccd..e6bda9c41d 100644 --- a/packages/features/bookings/lib/service/InstantBookingCreateService.ts +++ b/packages/features/bookings/lib/service/InstantBookingCreateService.ts @@ -197,9 +197,19 @@ export async function handler( const attendeeTimezone = reqBody.timeZone; const attendeeLanguage = reqBody.language; const tAttendees = await getTranslation(attendeeLanguage ?? "en", "common"); + const tEnglish = await getTranslation("en", "common"); const fullName = getFullName(bookerName); + // Determine whether to auto-translate the instant meeting title based on the event type setting + // Default is true (opt-out), so we only skip translation when explicitly set to false + const shouldAutoTranslateInstantMeetingTitle = eventType.autoTranslateInstantMeetingTitleEnabled; + + // Get the booking title - either translated to attendee's language or in English + const bookingTitle = shouldAutoTranslateInstantMeetingTitle + ? tAttendees("instant_meeting_with_title", { name: fullName }) + : tEnglish("instant_meeting_with_title", { name: fullName }); + const invitee = [ { email: bookerEmail, @@ -242,7 +252,7 @@ export async function handler( const newBookingData: Prisma.BookingCreateInput = { uid, responses: reqBody.responses === null ? Prisma.JsonNull : reqBody.responses, - title: tAttendees("instant_meeting_with_title", { name: invitee[0].name }), + title: bookingTitle, startTime: dayjs.utc(reqBody.start).toDate(), endTime: dayjs.utc(reqBody.end).toDate(), description: reqBody.notes, @@ -279,17 +289,7 @@ export async function handler( const token = randomBytes(32).toString("hex"); - const eventTypeWithExpiryTimeOffset = await prisma.eventType.findUniqueOrThrow({ - where: { - id: bookingData.eventTypeId, - }, - select: { - instantMeetingExpiryTimeOffsetInSeconds: true, - }, - }); - - const instantMeetingExpiryTimeOffsetInSeconds = - eventTypeWithExpiryTimeOffset?.instantMeetingExpiryTimeOffsetInSeconds ?? 90; + const instantMeetingExpiryTimeOffsetInSeconds = eventType.instantMeetingExpiryTimeOffsetInSeconds ?? 90; const instantMeetingToken = await prisma.instantMeetingToken.create({ data: { diff --git a/packages/features/eventtypes/components/tabs/instant/InstantEventController.tsx b/packages/features/eventtypes/components/tabs/instant/InstantEventController.tsx index 4d6674e441..a7074a32ef 100644 --- a/packages/features/eventtypes/components/tabs/instant/InstantEventController.tsx +++ b/packages/features/eventtypes/components/tabs/instant/InstantEventController.tsx @@ -245,6 +245,21 @@ export default function InstantEventController({ )} /> + ( + onChange(e)} + data-testid="auto-translate-instant-meeting-title-toggle" + toggleSwitchAtTheEnd={true} + switchContainerClassName="border-subtle rounded-lg border py-6 px-4 sm:px-6 mt-4" + description={t("auto_translate_instant_meeting_title_description")} + /> + )} + /> )} diff --git a/packages/features/eventtypes/lib/defaultEvents.ts b/packages/features/eventtypes/lib/defaultEvents.ts index 658c04e6e4..6be10c54e0 100644 --- a/packages/features/eventtypes/lib/defaultEvents.ts +++ b/packages/features/eventtypes/lib/defaultEvents.ts @@ -145,6 +145,7 @@ const commons = { requiresConfirmationWillBlockSlot: false, canSendCalVideoTranscriptionEmails: false, instantMeetingExpiryTimeOffsetInSeconds: 0, + autoTranslateInstantMeetingTitleEnabled: true, instantMeetingScheduleId: null, instantMeetingParameters: [], eventTypeColor: null, diff --git a/packages/features/eventtypes/lib/types.ts b/packages/features/eventtypes/lib/types.ts index b614d3e715..f286236517 100644 --- a/packages/features/eventtypes/lib/types.ts +++ b/packages/features/eventtypes/lib/types.ts @@ -135,6 +135,7 @@ export type FormValues = { seatsShowAvailabilityCount: boolean | null; seatsPerTimeSlotEnabled: boolean; autoTranslateDescriptionEnabled: boolean; + autoTranslateInstantMeetingTitleEnabled: boolean; fieldTranslations: EventTypeTranslation[]; scheduleName: string; minimumBookingNotice: number; diff --git a/packages/features/eventtypes/repositories/eventTypeRepository.ts b/packages/features/eventtypes/repositories/eventTypeRepository.ts index 983522cce4..d87ed8a0dc 100644 --- a/packages/features/eventtypes/repositories/eventTypeRepository.ts +++ b/packages/features/eventtypes/repositories/eventTypeRepository.ts @@ -570,6 +570,7 @@ export class EventTypeRepository { requiresConfirmationWillBlockSlot: true, requiresBookerEmailVerification: true, autoTranslateDescriptionEnabled: true, + autoTranslateInstantMeetingTitleEnabled: true, fieldTranslations: { select: { translatedText: true, @@ -868,6 +869,7 @@ export class EventTypeRepository { requiresConfirmationWillBlockSlot: true, requiresBookerEmailVerification: true, autoTranslateDescriptionEnabled: true, + autoTranslateInstantMeetingTitleEnabled: true, fieldTranslations: { select: { translatedText: true, diff --git a/packages/lib/test/builder.ts b/packages/lib/test/builder.ts index 119dff6bdb..8094388118 100644 --- a/packages/lib/test/builder.ts +++ b/packages/lib/test/builder.ts @@ -155,6 +155,7 @@ export const buildEventType = (eventType?: Partial): EventType => { assignRRMembersUsingSegment: false, rrSegmentQueryValue: null, autoTranslateDescriptionEnabled: false, + autoTranslateInstantMeetingTitleEnabled: true, useEventLevelSelectedCalendars: false, allowReschedulingPastBookings: false, hideOrganizerEmail: false, diff --git a/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts b/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts index 467e581e00..e68a08cca9 100644 --- a/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts +++ b/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts @@ -118,6 +118,7 @@ export const useEventTypeForm = ({ })), seatsPerTimeSlotEnabled: eventType.seatsPerTimeSlot, autoTranslateDescriptionEnabled: eventType.autoTranslateDescriptionEnabled, + autoTranslateInstantMeetingTitleEnabled: eventType.autoTranslateInstantMeetingTitleEnabled ?? true, rescheduleWithSameRoundRobinHost: eventType.rescheduleWithSameRoundRobinHost, assignAllTeamMembers: eventType.assignAllTeamMembers, assignRRMembersUsingSegment: eventType.assignRRMembersUsingSegment, diff --git a/packages/prisma/migrations/20251202143411_add_auto_translate_title_enabled/migration.sql b/packages/prisma/migrations/20251202143411_add_auto_translate_title_enabled/migration.sql new file mode 100644 index 0000000000..273175dbde --- /dev/null +++ b/packages/prisma/migrations/20251202143411_add_auto_translate_title_enabled/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "public"."EventType" ADD COLUMN "autoTranslateInstantMeetingTitleEnabled" BOOLEAN NOT NULL DEFAULT true; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 12aa23f021..dfa785bf36 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -182,6 +182,7 @@ model EventType { canSendCalVideoTranscriptionEmails Boolean @default(true) autoTranslateDescriptionEnabled Boolean @default(false) + autoTranslateInstantMeetingTitleEnabled Boolean @default(true) /// @zod.import(["import { recurringEventType } from '../../zod-utils'"]).custom.use(recurringEventType) recurringEvent Json? disableGuests Boolean @default(false) diff --git a/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts index 7e926f9b6e..3f8d24a2a6 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts @@ -93,6 +93,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { aiPhoneCallConfig, isRRWeightsEnabled, autoTranslateDescriptionEnabled, + autoTranslateInstantMeetingTitleEnabled, description: newDescription, title: newTitle, seatsPerTimeSlot, @@ -231,7 +232,8 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { const data: Prisma.EventTypeUpdateInput = { ...rest, - // autoTranslate feature is allowed for org users only + // Only update autoTranslateInstantMeetingTitleEnabled when explicitly provided to avoid overwriting saved opt-out + ...(autoTranslateInstantMeetingTitleEnabled !== undefined && { autoTranslateInstantMeetingTitleEnabled }), // Only set if explicitly provided to avoid overwriting existing value with false ...(autoTranslateDescriptionEnabled !== undefined && { autoTranslateDescriptionEnabled: Boolean(ctx.user.organizationId && autoTranslateDescriptionEnabled), diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.integration-test.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.integration-test.ts index eb0c59588d..ca4ede014f 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.integration-test.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.integration-test.ts @@ -1,10 +1,9 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { prisma } from "@calcom/prisma"; -import type { Team, User, Membership, Profile } from "@calcom/prisma/client"; +import type { Prisma, Team, User, Membership, Profile } from "@calcom/prisma/client"; import { MembershipRole } from "@calcom/prisma/enums"; import type { TrpcSessionUser } from "@calcom/trpc/server/types"; -import type { JsonValue } from "@calcom/types/Json"; import inviteMemberHandler, { inviteMembersWithNoInviterPermissionCheck } from "./inviteMember.handler"; @@ -66,7 +65,7 @@ async function createTestTeam(data: { slug: string; isOrganization?: boolean; parentId?: number; - metadata?: JsonValue; + metadata?: Prisma.TeamCreateInput["metadata"]; organizationSettings?: { orgAutoAcceptEmail: string; isOrganizationVerified?: boolean;