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 <j.auyeung419@gmail.com>

* fix: add autoTranslateTitleEnabled to test builder

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: respect autoTranslateTitleEnabled toggle in InstantBookingCreateService

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: fetch autoTranslateTitleEnabled directly in InstantBookingCreateService

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.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 <j.auyeung419@gmail.com>

* fix: add autoTranslateInstantMeetingTitleEnabled to test destructuring patterns

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: use Prisma.TeamCreateInput type for metadata in test helper

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: add autoTranslateInstantMeetingTitleEnabled to mockUpdatedEventType in test

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.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 <j.auyeung419@gmail.com>

* refactor: remove autoTranslateInstantMeetingTitleEnabled from eventTypeSelect (not needed for findByIdMinimal)

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.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 <j.auyeung419@gmail.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 <j.auyeung419@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Joe Au-Yeung
2025-12-09 13:51:51 +00:00
committed by GitHub
co-authored by Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent 6ae2ce8ed9
commit 3a59cd41cc
14 changed files with 57 additions and 17 deletions
@@ -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 visitors 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",
@@ -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,
@@ -181,6 +181,8 @@ const getEventTypesFromDBSelect = {
name: true,
},
},
instantMeetingExpiryTimeOffsetInSeconds: true,
autoTranslateInstantMeetingTitleEnabled: true,
} satisfies Prisma.EventTypeSelect;
export const getEventTypesFromDB = async (eventTypeId: number) => {
@@ -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: {
@@ -245,6 +245,21 @@ export default function InstantEventController({
</>
)}
/>
<Controller
name="autoTranslateInstantMeetingTitleEnabled"
render={({ field: { value, onChange } }) => (
<SettingsToggle
labelClassName="text-sm"
title={t("auto_translate_instant_meeting_title")}
checked={value}
onCheckedChange={(e) => 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")}
/>
)}
/>
<InstantMeetingWebhooks eventType={eventType} />
</div>
)}
@@ -145,6 +145,7 @@ const commons = {
requiresConfirmationWillBlockSlot: false,
canSendCalVideoTranscriptionEmails: false,
instantMeetingExpiryTimeOffsetInSeconds: 0,
autoTranslateInstantMeetingTitleEnabled: true,
instantMeetingScheduleId: null,
instantMeetingParameters: [],
eventTypeColor: null,
@@ -135,6 +135,7 @@ export type FormValues = {
seatsShowAvailabilityCount: boolean | null;
seatsPerTimeSlotEnabled: boolean;
autoTranslateDescriptionEnabled: boolean;
autoTranslateInstantMeetingTitleEnabled: boolean;
fieldTranslations: EventTypeTranslation[];
scheduleName: string;
minimumBookingNotice: number;
@@ -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,
+1
View File
@@ -155,6 +155,7 @@ export const buildEventType = (eventType?: Partial<EventType>): EventType => {
assignRRMembersUsingSegment: false,
rrSegmentQueryValue: null,
autoTranslateDescriptionEnabled: false,
autoTranslateInstantMeetingTitleEnabled: true,
useEventLevelSelectedCalendars: false,
allowReschedulingPastBookings: false,
hideOrganizerEmail: false,
@@ -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,
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "public"."EventType" ADD COLUMN "autoTranslateInstantMeetingTitleEnabled" BOOLEAN NOT NULL DEFAULT true;
+1
View File
@@ -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)
@@ -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),
@@ -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;