* feat: workflow auto translation * tests: add unit tests * refactor: tests and workflow * fix: type err * fix: type err * fix: remove redundant index on WorkflowStepTranslation The @@index on [workflowStepId, field, targetLocale] duplicates the @@unique constraint on the same columns. A unique index already provides efficient lookups, so the separate @@index adds storage overhead and write latency without benefit. Addresses Cubic AI review feedback (confidence 9/10). Co-Authored-By: unknown <> * fix: correct locale mapping when translation API returns null Map translations with their corresponding locales before filtering to preserve correct locale-to-translation associations. Previously, filtering out null translations would reindex the array, causing incorrect locale mappings when any translation in the batch failed. Also fixes pre-existing lint warnings: - Move exports to end of file - Add explicit return type to processTranslations - Replace ternary with if-else for upsertMany selection Co-Authored-By: udit@cal.com <udit222001@gmail.com> * fix: address review feedback for workflow auto-translation - Add change detection before creating translation tasks - Rename userLocale to sourceLocale in task props for clarity - Show source language in UI with new translation key - Extract SUPPORTED_LOCALES to shared translationConstants.ts - Fix locale mapping bug in translateEventTypeData.ts - Add WhatsApp translation support - Abstract translation lookup into shared translationLookup.ts helper - Restore if-else readability for SCANNING_WORKFLOW_STEPS Co-authored-by: Udit Takkar <udit.takkar@cal.com> Co-Authored-By: unknown <> * fix: update test to use sourceLocale instead of userLocale Co-Authored-By: unknown <> * refactor: feedback * fix: handle first time * fix: tests * fix: tests * fix: address Cubic AI review feedback (confidence 9/10 issues) - WhatsApp translation: Apply variable substitution using getSMSMessageWithVariables and clear contentSid when using translated body to ensure Twilio uses the translated text instead of the original template - update.handler.ts: Change sourceLocale assignment from ?? to || for consistency with tasker payload behavior (line 481) - ITranslationService.ts: Rename methods from plural to singular naming: - getWorkflowStepTranslations -> getWorkflowStepTranslation - getEventTypeTranslations -> getEventTypeTranslation Updated all call sites and tests accordingly Co-Authored-By: unknown <> * fix: address Cubic AI review feedback (confidence 9/10+ issues) - Fix getSMSMessageWithVariables to handle WHATSAPP_ATTENDEE action for locale and timezone (confidence 9/10) - Remove WhatsApp translation feature that set contentSid to undefined since Twilio ignores body parameter for WhatsApp and requires pre-approved Message Templates (confidence 10/10) Co-Authored-By: unknown <> * fix: translatio * Add tests: packages/features/eventTypeTranslation/repositories/EventTypeTranslationRepository.test.ts Generated by Paragon from proposal for PR #27087 * Add tests: packages/features/tasker/tasks/translateWorkflowStepData.test.ts Generated by Paragon from proposal for PR #27087 * chore: nit * chore: verfied atg * fix: set sourceLocale for new steps, add shouldDirty to checkbox, remove spec docs - Set sourceLocale fallback in addedSteps mapping to fix stale detection mismatch - Add { shouldDirty: true } to autoTranslateEnabled checkbox onChange - Remove specs/workflow-translation/ directory (planning docs, not for repo) Co-authored-by: Udit Takkar <udit.07.takkar@gmail.com> Co-Authored-By: unknown <> * chore: add specs back * fix: type error * fix: type error * fix: type err * fix: tests * refactor: feedback * fix: type err * refactor --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Udit Takkar <udit.takkar@cal.com> Co-authored-by: Udit Takkar <udit.07.takkar@gmail.com>
228 lines
8.7 KiB
TypeScript
228 lines
8.7 KiB
TypeScript
import { scheduleAIPhoneCall } from "@calcom/ee/workflows/lib/reminders/aiPhoneCallManager";
|
|
import { scheduleEmailReminder } from "@calcom/ee/workflows/lib/reminders/emailReminderManager";
|
|
import { scheduleSMSReminder } from "@calcom/ee/workflows/lib/reminders/smsReminderManager";
|
|
import { scheduleWhatsappReminder } from "@calcom/ee/workflows/lib/reminders/whatsappReminderManager";
|
|
import { CreditService } from "@calcom/features/ee/billing/credit-service";
|
|
import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer";
|
|
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
|
|
import type { WorkflowStep } from "@calcom/prisma/client";
|
|
import type { TimeUnit } from "@calcom/prisma/enums";
|
|
import { SchedulingType, WorkflowActions, WorkflowTriggerEvents } from "@calcom/prisma/enums";
|
|
import type { CalEventResponses } from "@calcom/types/Calendar";
|
|
|
|
import type { getBookings } from "./scheduleWorkflowNotifications";
|
|
import { verifyEmailSender } from "./verifyEmailSender";
|
|
|
|
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
|
|
type Bookings = UnwrapPromise<ReturnType<typeof getBookings>>;
|
|
|
|
// some parts of scheduleWorkflowReminders (reminderSchedule.ts) is quite similar to this code
|
|
// we should consider refactoring this to reuse similar code snippets
|
|
export async function scheduleBookingReminders(
|
|
bookings: Bookings,
|
|
workflowSteps: Partial<WorkflowStep>[],
|
|
time: number | null,
|
|
timeUnit: TimeUnit | null,
|
|
trigger: WorkflowTriggerEvents,
|
|
userId: number,
|
|
teamId: number | null,
|
|
isOrg: boolean
|
|
) {
|
|
if (!bookings.length) return;
|
|
if (trigger !== WorkflowTriggerEvents.BEFORE_EVENT && trigger !== WorkflowTriggerEvents.AFTER_EVENT) return;
|
|
|
|
const bookerUrl = await getBookerBaseUrl(isOrg ? teamId : null);
|
|
|
|
const creditService = new CreditService();
|
|
|
|
//create reminders for all bookings for each workflow step
|
|
const promiseSteps = workflowSteps.map(async (step) => {
|
|
const promiseScheduleReminders = bookings.map(async (booking) => {
|
|
const defaultLocale = "en";
|
|
const bookingInfo = {
|
|
uid: booking.uid,
|
|
bookerUrl,
|
|
type: booking.eventType?.slug || "event",
|
|
attendees: booking.attendees.map((attendee) => {
|
|
return {
|
|
name: attendee.name,
|
|
email: attendee.email,
|
|
timeZone: attendee.timeZone,
|
|
language: { locale: attendee.locale || defaultLocale },
|
|
};
|
|
}),
|
|
organizer: booking.user
|
|
? {
|
|
language: { locale: booking.user.locale || defaultLocale },
|
|
name: booking.user.name || "",
|
|
email: booking?.userPrimaryEmail ?? booking.user.email,
|
|
timeZone: booking.user.timeZone,
|
|
timeFormat: getTimeFormatStringFromUserTimeFormat(booking.user.timeFormat),
|
|
}
|
|
: { name: "", email: "", timeZone: "", language: { locale: "" } },
|
|
startTime: booking.startTime?.toISOString(),
|
|
endTime: booking.endTime?.toISOString(),
|
|
title: booking.title,
|
|
language: { locale: booking?.user?.locale || defaultLocale },
|
|
hideOrganizerEmail: booking.eventType?.hideOrganizerEmail,
|
|
eventType: {
|
|
slug: booking.eventType?.slug || "",
|
|
schedulingType: booking.eventType?.schedulingType,
|
|
hosts: booking.eventType?.hosts,
|
|
},
|
|
metadata: booking.metadata,
|
|
customReplyToEmail: booking.eventType?.customReplyToEmail,
|
|
responses: booking.responses as CalEventResponses | null,
|
|
};
|
|
if (
|
|
step.action === WorkflowActions.EMAIL_HOST ||
|
|
step.action === WorkflowActions.EMAIL_ATTENDEE ||
|
|
step.action === WorkflowActions.EMAIL_ADDRESS
|
|
) {
|
|
let sendTo: string[] = [];
|
|
|
|
switch (step.action) {
|
|
case WorkflowActions.EMAIL_HOST: {
|
|
sendTo = [bookingInfo.organizer?.email];
|
|
const schedulingType = bookingInfo.eventType.schedulingType;
|
|
const hosts = bookingInfo.eventType.hosts
|
|
?.filter((host) => bookingInfo.attendees.some((attendee) => attendee.email === host.user.email))
|
|
.map(({ user }) => user.destinationCalendar?.primaryEmail ?? user.email);
|
|
if (
|
|
hosts &&
|
|
(schedulingType === SchedulingType.ROUND_ROBIN || schedulingType === SchedulingType.COLLECTIVE)
|
|
) {
|
|
sendTo = sendTo.concat(hosts);
|
|
}
|
|
break;
|
|
}
|
|
case WorkflowActions.EMAIL_ATTENDEE:
|
|
sendTo = bookingInfo.attendees.map((attendee) => attendee.email);
|
|
break;
|
|
case WorkflowActions.EMAIL_ADDRESS:
|
|
await verifyEmailSender(step.sendTo || "", userId, teamId);
|
|
sendTo = [step.sendTo || ""];
|
|
break;
|
|
}
|
|
|
|
await scheduleEmailReminder({
|
|
evt: bookingInfo,
|
|
triggerEvent: trigger,
|
|
action: step.action,
|
|
timeSpan: {
|
|
time,
|
|
timeUnit,
|
|
},
|
|
sendTo,
|
|
emailSubject: step.emailSubject || "",
|
|
emailBody: step.reminderBody || "",
|
|
template: step.template,
|
|
sender: step.sender,
|
|
workflowStepId: step.id,
|
|
verifiedAt: step?.verifiedAt ?? null,
|
|
autoTranslateEnabled: step.autoTranslateEnabled,
|
|
sourceLocale: step.sourceLocale,
|
|
});
|
|
} else if (step.action === WorkflowActions.SMS_NUMBER && step.sendTo) {
|
|
await scheduleSMSReminder({
|
|
evt: bookingInfo,
|
|
reminderPhone: step.sendTo,
|
|
triggerEvent: trigger,
|
|
action: step.action,
|
|
timeSpan: {
|
|
time,
|
|
timeUnit,
|
|
},
|
|
message: step.reminderBody || "",
|
|
workflowStepId: step.id,
|
|
template: step.template,
|
|
sender: step.sender,
|
|
userId: userId,
|
|
teamId: teamId,
|
|
verifiedAt: step?.verifiedAt ?? null,
|
|
creditCheckFn: creditService.hasAvailableCredits.bind(creditService),
|
|
});
|
|
} else if (step.action === WorkflowActions.WHATSAPP_NUMBER && step.sendTo) {
|
|
await scheduleWhatsappReminder({
|
|
evt: bookingInfo,
|
|
reminderPhone: step.sendTo,
|
|
triggerEvent: trigger,
|
|
action: step.action,
|
|
timeSpan: {
|
|
time,
|
|
timeUnit,
|
|
},
|
|
message: step.reminderBody || "",
|
|
workflowStepId: step.id || 0,
|
|
template: step.template,
|
|
userId: userId,
|
|
teamId: teamId,
|
|
verifiedAt: step?.verifiedAt ?? null,
|
|
creditCheckFn: creditService.hasAvailableCredits.bind(creditService),
|
|
});
|
|
} else if (booking.smsReminderNumber) {
|
|
if (step.action === WorkflowActions.SMS_ATTENDEE) {
|
|
await scheduleSMSReminder({
|
|
evt: bookingInfo,
|
|
reminderPhone: booking.smsReminderNumber,
|
|
triggerEvent: trigger,
|
|
action: step.action,
|
|
timeSpan: {
|
|
time,
|
|
timeUnit,
|
|
},
|
|
message: step.reminderBody || "",
|
|
workflowStepId: step.id,
|
|
template: step.template,
|
|
sender: step.sender,
|
|
userId: userId,
|
|
teamId: teamId,
|
|
verifiedAt: step?.verifiedAt ?? null,
|
|
autoTranslateEnabled: step.autoTranslateEnabled,
|
|
sourceLocale: step.sourceLocale,
|
|
creditCheckFn: creditService.hasAvailableCredits.bind(creditService),
|
|
});
|
|
} else if (step.action === WorkflowActions.WHATSAPP_ATTENDEE) {
|
|
await scheduleWhatsappReminder({
|
|
evt: bookingInfo,
|
|
reminderPhone: booking.smsReminderNumber,
|
|
triggerEvent: trigger,
|
|
action: step.action,
|
|
timeSpan: {
|
|
time,
|
|
timeUnit,
|
|
},
|
|
message: step.reminderBody || "",
|
|
workflowStepId: step.id,
|
|
template: step.template,
|
|
sender: step.sender,
|
|
userId: userId,
|
|
teamId: teamId,
|
|
verifiedAt: step?.verifiedAt ?? null,
|
|
autoTranslateEnabled: step.autoTranslateEnabled,
|
|
sourceLocale: step.sourceLocale,
|
|
creditCheckFn: creditService.hasAvailableCredits.bind(creditService),
|
|
});
|
|
}
|
|
} else if (step.action === WorkflowActions.CAL_AI_PHONE_CALL) {
|
|
await scheduleAIPhoneCall({
|
|
evt: bookingInfo,
|
|
triggerEvent: trigger,
|
|
timeSpan: {
|
|
time,
|
|
timeUnit,
|
|
},
|
|
workflowStepId: step.id,
|
|
userId,
|
|
teamId,
|
|
verifiedAt: step?.verifiedAt ?? null,
|
|
submittedPhoneNumber: booking.smsReminderNumber,
|
|
routedEventTypeId: null,
|
|
});
|
|
}
|
|
});
|
|
await Promise.all(promiseScheduleReminders);
|
|
});
|
|
return Promise.all(promiseSteps);
|
|
}
|