* 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>
787 lines
29 KiB
TypeScript
Executable File
787 lines
29 KiB
TypeScript
Executable File
import { createDefaultAIPhoneServiceProvider } from "@calcom/features/calAIPhone";
|
|
import { PrismaAgentRepository } from "@calcom/features/calAIPhone/repositories/PrismaAgentRepository";
|
|
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
|
|
import {
|
|
isAttendeeAction,
|
|
isEmailAction,
|
|
isFormTrigger,
|
|
isWhatsappAction,
|
|
} from "@calcom/features/ee/workflows/lib/actionHelperFunctions";
|
|
import { deleteRemindersOfActiveOnIds } from "@calcom/features/ee/workflows/lib/deleteRemindersOfActiveOnIds";
|
|
import { isAuthorized } from "@calcom/features/ee/workflows/lib/isAuthorized";
|
|
import { WorkflowReminderRepository } from "@calcom/features/ee/workflows/lib/repository/workflowReminder";
|
|
import { scheduleWorkflowNotifications } from "@calcom/features/ee/workflows/lib/scheduleWorkflowNotifications";
|
|
import { verifyEmailSender } from "@calcom/features/ee/workflows/lib/verifyEmailSender";
|
|
import { WorkflowRelationsRepository } from "@calcom/features/ee/workflows/repositories/WorkflowRelationsRepository";
|
|
import { WorkflowRepository } from "@calcom/features/ee/workflows/repositories/WorkflowRepository";
|
|
import { WorkflowStepRepository } from "@calcom/features/ee/workflows/repositories/WorkflowStepRepository";
|
|
import { EventTypeRepository } from "@calcom/features/eventtypes/repositories/eventTypeRepository";
|
|
import tasker from "@calcom/features/tasker";
|
|
import { addPermissionsToWorkflow } from "@calcom/features/workflows/repositories/WorkflowPermissionsRepository";
|
|
import { IS_SELF_HOSTED, SCANNING_WORKFLOW_STEPS } from "@calcom/lib/constants";
|
|
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
|
|
import logger from "@calcom/lib/logger";
|
|
import { type PrismaClient, prisma } from "@calcom/prisma";
|
|
import { PhoneNumberSubscriptionStatus, WorkflowActions, WorkflowTemplates } from "@calcom/prisma/enums";
|
|
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
|
|
import { TRPCError } from "@trpc/server";
|
|
import hasActiveTeamPlanHandler from "../teams/hasActiveTeamPlan.handler";
|
|
import type { TUpdateInputSchema } from "./update.schema";
|
|
import {
|
|
getEmailTemplateText,
|
|
getSender,
|
|
isAuthorizedToAddActiveOnIds,
|
|
isStepEdited,
|
|
removeAIAgentCallPhoneNumberFieldForEventTypes,
|
|
removeSmsReminderFieldForEventTypes,
|
|
upsertAIAgentCallPhoneNumberFieldForEventTypes,
|
|
upsertSmsReminderFieldForEventTypes,
|
|
} from "./util";
|
|
|
|
type UpdateOptions = {
|
|
ctx: {
|
|
user: Pick<
|
|
NonNullable<TrpcSessionUser>,
|
|
"id" | "metadata" | "locale" | "timeFormat" | "timeZone" | "organizationId"
|
|
>;
|
|
prisma: PrismaClient;
|
|
};
|
|
input: TUpdateInputSchema;
|
|
};
|
|
const log = logger.getSubLogger({ prefix: ["[Workflows.update] "] });
|
|
|
|
export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
|
const { user } = ctx;
|
|
const {
|
|
id,
|
|
name,
|
|
activeOnEventTypeIds,
|
|
activeOnRoutingFormIds,
|
|
steps,
|
|
trigger,
|
|
time,
|
|
timeUnit,
|
|
isActiveOnAll,
|
|
} = input;
|
|
|
|
const userWorkflow = await WorkflowRepository.findUniqueForUpdate(id);
|
|
|
|
const isOrg = !!userWorkflow?.team?.isOrganization;
|
|
|
|
const isUserAuthorized = await isAuthorized(userWorkflow, ctx.user.id, "workflow.update");
|
|
|
|
if (!isUserAuthorized || !userWorkflow) {
|
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
|
}
|
|
|
|
if (steps.find((step) => step.workflowId != id)) {
|
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
|
}
|
|
|
|
const isCurrentUsernamePremium = hasKeyInMetadata(user, "isPremium") ? !!user.metadata.isPremium : false;
|
|
|
|
let teamsPlan = { isActive: false, isTrial: false };
|
|
if (!isCurrentUsernamePremium) {
|
|
teamsPlan = await hasActiveTeamPlanHandler({ ctx, input: { ownerOnly: false } });
|
|
}
|
|
const hasPaidPlan = IS_SELF_HOSTED || isCurrentUsernamePremium || teamsPlan.isActive;
|
|
let newActiveOn: number[] = [];
|
|
|
|
let removedActiveOnIds: number[] = [];
|
|
|
|
let activeOnWithChildren: number[] = activeOnEventTypeIds;
|
|
|
|
let oldActiveOnIds: number[] = [];
|
|
|
|
const workflowRelationsRepository = new WorkflowRelationsRepository(ctx.prisma);
|
|
|
|
if (isOrg) {
|
|
// activeOn are team ids
|
|
if (userWorkflow.isActiveOnAll) {
|
|
const teamRepo = new TeamRepository(ctx.prisma);
|
|
oldActiveOnIds = (
|
|
await teamRepo.findAllByParentId({ parentId: userWorkflow.teamId ?? 0, select: { id: true } })
|
|
).map((team) => team.id);
|
|
} else {
|
|
oldActiveOnIds = (await workflowRelationsRepository.findActiveOnTeams(id)).map(
|
|
(teamRel) => teamRel.teamId
|
|
);
|
|
}
|
|
|
|
newActiveOn = activeOnEventTypeIds.filter((teamId) => !oldActiveOnIds.includes(teamId));
|
|
|
|
const isAuthorizedToAddIds = await isAuthorizedToAddActiveOnIds({
|
|
newEventTypeIds: [], // No event type IDs for team workflows
|
|
newRoutingFormIds: [], // No routing form IDs for team workflows
|
|
newTeamIds: newActiveOn,
|
|
teamId: userWorkflow?.teamId,
|
|
userId: userWorkflow?.userId,
|
|
});
|
|
|
|
if (!isAuthorizedToAddIds) {
|
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
|
}
|
|
|
|
removedActiveOnIds = oldActiveOnIds.filter((teamId) => !activeOnEventTypeIds.includes(teamId));
|
|
|
|
await deleteRemindersOfActiveOnIds({
|
|
removedActiveOnIds,
|
|
workflowSteps: userWorkflow.steps,
|
|
isOrg,
|
|
activeOnIds: activeOnEventTypeIds.filter((activeOn) => !newActiveOn.includes(activeOn)),
|
|
});
|
|
|
|
// clean up any old active on values
|
|
await workflowRelationsRepository.deleteAllActiveOnRelations(id);
|
|
|
|
// create all new active on relationships
|
|
await workflowRelationsRepository.createActiveOnTeams(id, activeOnEventTypeIds);
|
|
} else if (isFormTrigger(trigger)) {
|
|
// activeOnRoutingFormIds are routing form ids
|
|
const routingFormIds = activeOnRoutingFormIds;
|
|
|
|
const isAuthorizedToAddIds = await isAuthorizedToAddActiveOnIds({
|
|
newEventTypeIds: [], // No event type IDs for form triggers
|
|
newRoutingFormIds: routingFormIds ?? [],
|
|
newTeamIds: [], // No team IDs for form triggers
|
|
teamId: userWorkflow?.teamId,
|
|
userId: userWorkflow?.userId,
|
|
});
|
|
|
|
if (!isAuthorizedToAddIds) {
|
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
|
}
|
|
|
|
// clean up any old active on values
|
|
await workflowRelationsRepository.deleteAllActiveOnRelations(id);
|
|
|
|
// Create new workflow - routing forms relationships
|
|
await workflowRelationsRepository.createActiveOnRoutingForms(id, routingFormIds ?? []);
|
|
} else {
|
|
const eventTypeRepo = new EventTypeRepository(ctx.prisma);
|
|
const activeOnEventTypes = await eventTypeRepo.findEventTypesWithoutChildren(
|
|
activeOnEventTypeIds,
|
|
userWorkflow.teamId
|
|
);
|
|
|
|
activeOnWithChildren = activeOnEventTypes.flatMap((eventType) =>
|
|
[eventType.id].concat(eventType.children.map((child) => child.id))
|
|
);
|
|
|
|
let oldActiveOnEventTypes: { id: number; children: { id: number }[] }[];
|
|
if (userWorkflow.isActiveOnAll) {
|
|
oldActiveOnEventTypes = userWorkflow.teamId
|
|
? await eventTypeRepo.findAllIncludingChildrenByTeamId({
|
|
teamId: userWorkflow.teamId,
|
|
})
|
|
: await eventTypeRepo.findAllIncludingChildrenByUserId({
|
|
userId: userWorkflow.userId,
|
|
});
|
|
} else {
|
|
oldActiveOnEventTypes = (await workflowRelationsRepository.findActiveOnEventTypes(id)).map(
|
|
(eventTypeRel) => {
|
|
return { id: eventTypeRel.eventTypeId, children: eventTypeRel.eventType.children };
|
|
}
|
|
);
|
|
}
|
|
|
|
oldActiveOnIds = oldActiveOnEventTypes.flatMap((eventType) => [
|
|
eventType.id,
|
|
...eventType.children.map((child) => child.id),
|
|
]);
|
|
|
|
newActiveOn = activeOnEventTypeIds.filter((eventTypeId) => !oldActiveOnIds.includes(eventTypeId));
|
|
const isAuthorizedToAddIds = await isAuthorizedToAddActiveOnIds({
|
|
newEventTypeIds: newActiveOn,
|
|
newRoutingFormIds: [],
|
|
newTeamIds: [],
|
|
teamId: userWorkflow?.teamId,
|
|
userId: userWorkflow?.userId,
|
|
});
|
|
|
|
if (!isAuthorizedToAddIds) {
|
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
|
}
|
|
|
|
//remove all scheduled Email and SMS reminders for eventTypes that are not active any more
|
|
removedActiveOnIds = oldActiveOnIds.filter((eventTypeId) => !activeOnWithChildren.includes(eventTypeId));
|
|
|
|
// clean up any old active on values
|
|
await deleteRemindersOfActiveOnIds({ removedActiveOnIds, workflowSteps: userWorkflow.steps, isOrg });
|
|
|
|
// clean up an old relationships
|
|
await workflowRelationsRepository.deleteAllActiveOnRelations(id);
|
|
|
|
//create all workflow - eventtypes relationships
|
|
await workflowRelationsRepository.createActiveOnEventTypes(id, activeOnWithChildren);
|
|
}
|
|
|
|
if (userWorkflow.trigger !== trigger || userWorkflow.time !== time || userWorkflow.timeUnit !== timeUnit) {
|
|
if (!isFormTrigger(userWorkflow.trigger)) {
|
|
// Delete all existing reminders before rescheduling
|
|
await deleteRemindersOfActiveOnIds({
|
|
removedActiveOnIds: oldActiveOnIds,
|
|
workflowSteps: userWorkflow.steps,
|
|
isOrg,
|
|
});
|
|
}
|
|
|
|
if (!isFormTrigger(trigger)) {
|
|
// Schedule new reminders for all activeOn
|
|
await scheduleWorkflowNotifications({
|
|
activeOn: activeOnWithChildren, // schedule for activeOn that stayed the same + new active on (old reminders were deleted)
|
|
isOrg,
|
|
workflowSteps: userWorkflow.steps, // use old steps here, edited and deleted steps are handled below
|
|
time,
|
|
timeUnit,
|
|
trigger,
|
|
userId: user.id,
|
|
teamId: userWorkflow.teamId,
|
|
});
|
|
}
|
|
} else {
|
|
// if trigger didn't change, only schedule reminders for all new activeOn
|
|
await scheduleWorkflowNotifications({
|
|
activeOn: newActiveOn,
|
|
isOrg,
|
|
workflowSteps: userWorkflow.steps, // use old steps here, edited and deleted steps are handled below
|
|
time,
|
|
timeUnit,
|
|
trigger,
|
|
userId: user.id,
|
|
teamId: userWorkflow.teamId,
|
|
alreadyScheduledActiveOnIds: activeOnEventTypeIds.filter((activeOn) => !newActiveOn.includes(activeOn)), // alreadyScheduledActiveOnIds
|
|
});
|
|
}
|
|
|
|
const workflowStepRepository = new WorkflowStepRepository(ctx.prisma);
|
|
|
|
const agentRepo = new PrismaAgentRepository(prisma);
|
|
|
|
const stepIds = userWorkflow.steps.map((step) => step.id);
|
|
const allReminders = await WorkflowReminderRepository.findWorkflowRemindersByStepIds(stepIds);
|
|
|
|
const remindersByStepId = new Map<number, (typeof allReminders)[number][]>();
|
|
for (const reminder of allReminders) {
|
|
const stepId = reminder.workflowStepId;
|
|
if (stepId === null) continue;
|
|
|
|
let list = remindersByStepId.get(stepId);
|
|
if (!list) {
|
|
list = [];
|
|
remindersByStepId.set(stepId, list);
|
|
}
|
|
list.push(reminder);
|
|
}
|
|
|
|
// handle deleted and edited workflow steps
|
|
await Promise.all(
|
|
userWorkflow.steps.map(async (oldStep) => {
|
|
const foundStep = steps.find((s) => s.id === oldStep.id);
|
|
let newStep;
|
|
|
|
if (foundStep) {
|
|
newStep = {
|
|
...foundStep,
|
|
numberVerificationPending: false,
|
|
sender: getSender({
|
|
action: foundStep.action,
|
|
sender: foundStep.sender || null,
|
|
senderName: foundStep.senderName,
|
|
}),
|
|
};
|
|
}
|
|
|
|
const remindersFromStep = remindersByStepId.get(oldStep.id) || [];
|
|
//step was deleted
|
|
if (!newStep) {
|
|
if (oldStep.action === WorkflowActions.CAL_AI_PHONE_CALL && !!oldStep.agentId) {
|
|
const agent = await agentRepo.findAgentWithPhoneNumbers(oldStep.agentId ?? undefined);
|
|
|
|
if (!agent) {
|
|
throw new TRPCError({
|
|
code: "NOT_FOUND",
|
|
message: `Agent ${oldStep.agentId} not found for cleanup`,
|
|
});
|
|
}
|
|
|
|
await WorkflowRepository.deleteAllWorkflowReminders(remindersFromStep);
|
|
await workflowStepRepository.deleteById(oldStep.id);
|
|
|
|
const aiPhoneService = createDefaultAIPhoneServiceProvider();
|
|
const externalErrors: string[] = [];
|
|
|
|
const phoneNumberOperations: Promise<void | { success: boolean; message: string }>[] = [];
|
|
|
|
if (agent.outboundPhoneNumbers) {
|
|
for (const phoneNumber of agent.outboundPhoneNumbers) {
|
|
if (phoneNumber.subscriptionStatus === PhoneNumberSubscriptionStatus.ACTIVE) {
|
|
try {
|
|
phoneNumberOperations.push(
|
|
aiPhoneService
|
|
.cancelPhoneNumberSubscription({
|
|
phoneNumberId: phoneNumber.id,
|
|
userId: user.id,
|
|
})
|
|
.catch((error) => {
|
|
const message = `Failed to cancel phone number subscription ${
|
|
phoneNumber.phoneNumber
|
|
}: ${error instanceof Error ? error.message : "Unknown error"}`;
|
|
externalErrors.push(message);
|
|
log.error(message);
|
|
})
|
|
);
|
|
} catch (error) {
|
|
const message = `Failed to cancel phone number subscription ${phoneNumber.phoneNumber}: ${
|
|
error instanceof Error ? error.message : "Unknown error"
|
|
}`;
|
|
externalErrors.push(message);
|
|
log.error(message);
|
|
phoneNumberOperations.push(Promise.resolve());
|
|
}
|
|
} else if (
|
|
phoneNumber.subscriptionStatus === null ||
|
|
phoneNumber.subscriptionStatus === undefined
|
|
) {
|
|
try {
|
|
phoneNumberOperations.push(
|
|
aiPhoneService
|
|
.deletePhoneNumber({
|
|
phoneNumber: phoneNumber.phoneNumber,
|
|
userId: user.id,
|
|
deleteFromDB: true,
|
|
})
|
|
.catch((error) => {
|
|
const message = `Failed to delete phone number ${phoneNumber.phoneNumber}: ${
|
|
error instanceof Error ? error.message : "Unknown error"
|
|
}`;
|
|
externalErrors.push(message);
|
|
log.error(message);
|
|
})
|
|
);
|
|
} catch (error) {
|
|
const message = `Failed to delete phone number ${phoneNumber.phoneNumber}: ${
|
|
error instanceof Error ? error.message : "Unknown error"
|
|
}`;
|
|
externalErrors.push(message);
|
|
log.error(message);
|
|
phoneNumberOperations.push(Promise.resolve());
|
|
}
|
|
}
|
|
// Skip cancelled phone numbers - they don't need any action
|
|
}
|
|
}
|
|
|
|
await Promise.all(phoneNumberOperations);
|
|
|
|
try {
|
|
await aiPhoneService.deleteAgent({
|
|
id: agent.id,
|
|
userId: user.id,
|
|
teamId: userWorkflow.teamId ?? undefined,
|
|
});
|
|
} catch (error) {
|
|
const message = `Failed to delete agent ${agent.id} from external service: ${
|
|
error instanceof Error ? error.message : "Unknown error"
|
|
}`;
|
|
externalErrors.push(message);
|
|
log.error(message);
|
|
}
|
|
|
|
if (oldStep.inboundAgentId) {
|
|
try {
|
|
await aiPhoneService.deleteAgent({
|
|
id: oldStep.inboundAgentId,
|
|
userId: user.id,
|
|
teamId: userWorkflow.teamId ?? undefined,
|
|
});
|
|
} catch (error) {
|
|
const message = `Failed to delete inbound agent ${
|
|
oldStep.inboundAgentId
|
|
} from external service: ${error instanceof Error ? error.message : "Unknown error"}`;
|
|
externalErrors.push(message);
|
|
log.error(message);
|
|
}
|
|
}
|
|
|
|
// If there were external errors, we should log them for manual cleanup
|
|
// but the operation is considered successful since DB is consistent
|
|
if (externalErrors.length > 0) {
|
|
log.error(`External service cleanup errors for workflow step ${oldStep.id}:`, externalErrors);
|
|
}
|
|
} else {
|
|
// For non-AI phone steps, just delete reminders and step
|
|
await WorkflowRepository.deleteAllWorkflowReminders(remindersFromStep);
|
|
await workflowStepRepository.deleteById(oldStep.id);
|
|
}
|
|
} else if (
|
|
isStepEdited(oldStep, {
|
|
...newStep,
|
|
verifiedAt: oldStep.verifiedAt,
|
|
agentId: newStep.agentId || null,
|
|
inboundAgentId: newStep.inboundAgentId || null,
|
|
sourceLocale: newStep.sourceLocale ?? null,
|
|
autoTranslateEnabled: newStep.autoTranslateEnabled ?? false,
|
|
})
|
|
) {
|
|
// check if step that require team plan already existed before
|
|
if (!hasPaidPlan && isEmailAction(newStep.action)) {
|
|
const isChangingToCustomTemplate =
|
|
newStep.template === WorkflowTemplates.CUSTOM && oldStep.template !== WorkflowTemplates.CUSTOM;
|
|
|
|
if (isChangingToCustomTemplate) {
|
|
throw new TRPCError({ code: "UNAUTHORIZED", message: "Not available on free plan" });
|
|
}
|
|
|
|
//if email body or subject was changed, change to predefined template
|
|
if (
|
|
newStep.emailSubject !== oldStep.emailSubject ||
|
|
newStep.reminderBody !== oldStep.reminderBody
|
|
) {
|
|
// already existing custom templates can't be updated
|
|
if (newStep.template === WorkflowTemplates.CUSTOM) {
|
|
throw new TRPCError({ code: "UNAUTHORIZED", message: "Not available on free plan" });
|
|
}
|
|
|
|
// on free plans always use predefined templates
|
|
const { emailBody, emailSubject } = await getEmailTemplateText(newStep.template, {
|
|
locale: ctx.user.locale,
|
|
action: newStep.action,
|
|
timeFormat: ctx.user.timeFormat,
|
|
});
|
|
|
|
newStep = { ...newStep, reminderBody: emailBody, emailSubject };
|
|
}
|
|
}
|
|
|
|
// update step
|
|
const requiresSender =
|
|
newStep.action === WorkflowActions.SMS_NUMBER ||
|
|
newStep.action === WorkflowActions.WHATSAPP_NUMBER ||
|
|
newStep.action === WorkflowActions.EMAIL_ADDRESS;
|
|
|
|
if (newStep.action === WorkflowActions.EMAIL_ADDRESS) {
|
|
await verifyEmailSender(newStep.sendTo || "", user.id, userWorkflow.teamId);
|
|
}
|
|
|
|
const didBodyChange = newStep.reminderBody !== oldStep.reminderBody;
|
|
const didSubjectChange = newStep.emailSubject !== oldStep.emailSubject;
|
|
const didSourceLocaleChange = newStep.sourceLocale !== oldStep.sourceLocale;
|
|
const didAutoTranslateEnable = !oldStep.autoTranslateEnabled && Boolean(newStep.autoTranslateEnabled);
|
|
const nextVerifiedAt = (() => {
|
|
if (!SCANNING_WORKFLOW_STEPS) return new Date();
|
|
if (didBodyChange) return null;
|
|
return oldStep.verifiedAt;
|
|
})();
|
|
|
|
await workflowStepRepository.updateWorkflowStep(oldStep.id, {
|
|
action: newStep.action,
|
|
sendTo: requiresSender ? newStep.sendTo : null,
|
|
stepNumber: newStep.stepNumber,
|
|
reminderBody: newStep.reminderBody,
|
|
emailSubject: newStep.emailSubject,
|
|
template: newStep.template,
|
|
numberRequired: newStep.numberRequired,
|
|
sender: newStep.sender,
|
|
numberVerificationPending: false,
|
|
includeCalendarEvent: newStep.includeCalendarEvent,
|
|
agentId: newStep.agentId || null,
|
|
verifiedAt: nextVerifiedAt,
|
|
autoTranslateEnabled: ctx.user.organizationId ? (newStep.autoTranslateEnabled ?? false) : false,
|
|
sourceLocale: newStep.sourceLocale || ctx.user.locale,
|
|
});
|
|
|
|
const shouldTranslate =
|
|
ctx.user.organizationId &&
|
|
newStep.autoTranslateEnabled &&
|
|
isAttendeeAction(newStep.action) &&
|
|
!isWhatsappAction(newStep.action) &&
|
|
(newStep.reminderBody || newStep.emailSubject) &&
|
|
(didBodyChange || didSubjectChange || didSourceLocaleChange || didAutoTranslateEnable);
|
|
|
|
if (shouldTranslate) {
|
|
await tasker.create("translateWorkflowStepData", {
|
|
workflowStepId: oldStep.id,
|
|
reminderBody: newStep.reminderBody,
|
|
emailSubject: newStep.emailSubject,
|
|
sourceLocale: newStep.sourceLocale || ctx.user.locale,
|
|
});
|
|
}
|
|
|
|
if (SCANNING_WORKFLOW_STEPS && didBodyChange) {
|
|
await tasker.create("scanWorkflowBody", {
|
|
workflowStepId: oldStep.id,
|
|
userId: ctx.user.id,
|
|
createdAt: new Date().toISOString(),
|
|
});
|
|
} else if (!isFormTrigger(trigger)) {
|
|
// schedule notifications for edited steps (only for event-based triggers)
|
|
await scheduleWorkflowNotifications({
|
|
activeOn: activeOnEventTypeIds,
|
|
isOrg,
|
|
workflowSteps: [{ ...newStep, verifiedAt: nextVerifiedAt }],
|
|
time,
|
|
timeUnit,
|
|
trigger,
|
|
userId: user.id,
|
|
teamId: userWorkflow.teamId,
|
|
});
|
|
}
|
|
|
|
// cancel all notifications of edited step
|
|
await WorkflowRepository.deleteAllWorkflowReminders(remindersFromStep);
|
|
}
|
|
})
|
|
);
|
|
|
|
// handle added workflow steps
|
|
const addedSteps = await Promise.all(
|
|
steps
|
|
.filter((step) => step.id <= 0)
|
|
.map(async (newStep) => {
|
|
if (!hasPaidPlan && isEmailAction(newStep.action)) {
|
|
if (newStep.template === WorkflowTemplates.CUSTOM) {
|
|
throw new TRPCError({ code: "UNAUTHORIZED", message: "Not available on free plan" });
|
|
}
|
|
// on free plans always use predefined templates
|
|
const { emailBody, emailSubject } = await getEmailTemplateText(newStep.template, {
|
|
locale: ctx.user.locale,
|
|
action: newStep.action,
|
|
timeFormat: ctx.user.timeFormat,
|
|
});
|
|
|
|
newStep = { ...newStep, reminderBody: emailBody, emailSubject };
|
|
}
|
|
|
|
if (newStep.action === WorkflowActions.EMAIL_ADDRESS) {
|
|
await verifyEmailSender(newStep.sendTo || "", user.id, userWorkflow.teamId);
|
|
}
|
|
|
|
return {
|
|
...newStep,
|
|
sender: getSender({
|
|
action: newStep.action,
|
|
sender: newStep.sender || null,
|
|
senderName: newStep.senderName,
|
|
}),
|
|
id: undefined,
|
|
senderName: undefined,
|
|
autoTranslateEnabled: ctx.user.organizationId ? (newStep.autoTranslateEnabled ?? false) : false,
|
|
sourceLocale: newStep.sourceLocale || ctx.user.locale,
|
|
};
|
|
})
|
|
);
|
|
|
|
if (addedSteps.length) {
|
|
//create new steps
|
|
const createdSteps = await Promise.all(
|
|
addedSteps.map((step) =>
|
|
workflowStepRepository.createWorkflowStep({
|
|
...step,
|
|
workflowId: id,
|
|
numberVerificationPending: false,
|
|
...(!SCANNING_WORKFLOW_STEPS ? { verifiedAt: new Date() } : {}),
|
|
})
|
|
)
|
|
);
|
|
|
|
if (SCANNING_WORKFLOW_STEPS) {
|
|
await Promise.all(
|
|
createdSteps.map((step) =>
|
|
tasker.create("scanWorkflowBody", {
|
|
workflowStepId: step.id,
|
|
userId: ctx.user.id,
|
|
createdAt: new Date().toISOString(),
|
|
})
|
|
)
|
|
);
|
|
}
|
|
|
|
if (ctx.user.organizationId) {
|
|
await Promise.all(
|
|
createdSteps
|
|
.filter(
|
|
(step) =>
|
|
step.autoTranslateEnabled &&
|
|
isAttendeeAction(step.action) &&
|
|
!isWhatsappAction(step.action) &&
|
|
(step.reminderBody || step.emailSubject)
|
|
)
|
|
.map((step) =>
|
|
tasker.create("translateWorkflowStepData", {
|
|
workflowStepId: step.id,
|
|
reminderBody: step.reminderBody,
|
|
emailSubject: step.emailSubject,
|
|
sourceLocale: step.sourceLocale || ctx.user.locale,
|
|
})
|
|
)
|
|
);
|
|
}
|
|
|
|
if (!SCANNING_WORKFLOW_STEPS && !isFormTrigger(trigger)) {
|
|
// schedule notification for new step (only for event-based triggers)
|
|
await scheduleWorkflowNotifications({
|
|
activeOn: activeOnEventTypeIds,
|
|
isOrg,
|
|
workflowSteps: createdSteps,
|
|
time,
|
|
timeUnit,
|
|
trigger,
|
|
userId: user.id,
|
|
teamId: userWorkflow.teamId,
|
|
});
|
|
}
|
|
}
|
|
|
|
//update trigger, name, time, timeUnit
|
|
await WorkflowRepository.updateWorkflow(id, {
|
|
name,
|
|
trigger,
|
|
time,
|
|
timeUnit,
|
|
isActiveOnAll,
|
|
});
|
|
|
|
const workflow = await WorkflowRepository.findUniqueWithRelations(id);
|
|
|
|
if (!workflow) {
|
|
throw new TRPCError({
|
|
code: "NOT_FOUND",
|
|
message: "Workflow not found after update",
|
|
});
|
|
}
|
|
|
|
// Remove or add booking field for sms reminder number (only for event types, not routing forms)
|
|
if (!isFormTrigger(trigger)) {
|
|
const smsReminderNumberNeeded =
|
|
activeOnWithChildren.length &&
|
|
steps.some(
|
|
(step) =>
|
|
step.action === WorkflowActions.SMS_ATTENDEE || step.action === WorkflowActions.WHATSAPP_ATTENDEE
|
|
);
|
|
|
|
await removeSmsReminderFieldForEventTypes({
|
|
activeOnToRemove: removedActiveOnIds,
|
|
workflowId: id,
|
|
isOrg,
|
|
activeOn: activeOnWithChildren,
|
|
});
|
|
|
|
if (!smsReminderNumberNeeded) {
|
|
await removeSmsReminderFieldForEventTypes({
|
|
activeOnToRemove: activeOnWithChildren,
|
|
workflowId: id,
|
|
isOrg,
|
|
});
|
|
} else {
|
|
await upsertSmsReminderFieldForEventTypes({
|
|
activeOn: activeOnWithChildren,
|
|
workflowId: id,
|
|
isSmsReminderNumberRequired: steps.some(
|
|
(s) =>
|
|
(s.action === WorkflowActions.SMS_ATTENDEE || s.action === WorkflowActions.WHATSAPP_ATTENDEE) &&
|
|
s.numberRequired
|
|
),
|
|
isOrg,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Remove or add attendeePhoneNumber field for AI phone call actions
|
|
const aiPhoneCallStepsNeeded =
|
|
activeOnWithChildren.length && steps.some((s) => s.action === WorkflowActions.CAL_AI_PHONE_CALL);
|
|
|
|
await removeAIAgentCallPhoneNumberFieldForEventTypes({
|
|
activeOnToRemove: removedActiveOnIds,
|
|
workflowId: id,
|
|
isOrg,
|
|
activeOn: activeOnWithChildren,
|
|
});
|
|
|
|
if (!aiPhoneCallStepsNeeded) {
|
|
await removeAIAgentCallPhoneNumberFieldForEventTypes({
|
|
activeOnToRemove: activeOnWithChildren,
|
|
workflowId: id,
|
|
isOrg,
|
|
});
|
|
} else {
|
|
await upsertAIAgentCallPhoneNumberFieldForEventTypes({
|
|
activeOn: activeOnWithChildren,
|
|
workflowId: id,
|
|
isAIAgentCallPhoneNumberRequired: steps.some((s) => s.action === WorkflowActions.CAL_AI_PHONE_CALL),
|
|
isOrg,
|
|
});
|
|
}
|
|
|
|
const aiPhoneCallSteps = steps.filter((s) => s.action === WorkflowActions.CAL_AI_PHONE_CALL && s.agentId);
|
|
if (aiPhoneCallSteps.length > 0) {
|
|
const aiService = createDefaultAIPhoneServiceProvider();
|
|
const externalToolErrors: string[] = [];
|
|
|
|
await Promise.all(
|
|
aiPhoneCallSteps.map(async (step) => {
|
|
if (!step.agentId) return;
|
|
|
|
try {
|
|
const agent = await agentRepo.findProviderAgentIdById(step.agentId);
|
|
|
|
if (!agent?.providerAgentId) {
|
|
log.error(`Agent not found for step ${step.id} agentId ${step.agentId}`);
|
|
return;
|
|
}
|
|
|
|
if (removedActiveOnIds.length > 0) {
|
|
try {
|
|
await aiService.removeToolsForEventTypes(agent.providerAgentId, removedActiveOnIds);
|
|
} catch (error) {
|
|
const message = `Failed to remove tools for removed event types from agent ${
|
|
agent.providerAgentId
|
|
}: ${error instanceof Error ? error.message : "Unknown error"}`;
|
|
externalToolErrors.push(message);
|
|
log.error(message);
|
|
}
|
|
}
|
|
|
|
if (newActiveOn.length > 0) {
|
|
await Promise.all(
|
|
newActiveOn.map(async (eventTypeId) => {
|
|
try {
|
|
await aiService.updateToolsFromAgentId(agent.providerAgentId, {
|
|
eventTypeId,
|
|
timeZone: user?.timeZone ?? "Europe/London",
|
|
userId: user.id,
|
|
teamId: userWorkflow.teamId || undefined,
|
|
});
|
|
} catch (error) {
|
|
const message = `${error instanceof Error ? error.message : "Unknown error"}`;
|
|
externalToolErrors.push(message);
|
|
log.error(message);
|
|
}
|
|
})
|
|
);
|
|
}
|
|
} catch (error) {
|
|
const message = `Failed to update agent tools for step ${step.id}: ${
|
|
error instanceof Error ? error.message : "Unknown error"
|
|
}`;
|
|
externalToolErrors.push(message);
|
|
log.error(message);
|
|
}
|
|
})
|
|
);
|
|
|
|
if (externalToolErrors.length > 0) {
|
|
log.error(`Agent tool update errors for workflow ${id}:`, externalToolErrors);
|
|
throw new TRPCError({
|
|
code: "INTERNAL_SERVER_ERROR",
|
|
message: externalToolErrors.join("; "),
|
|
});
|
|
}
|
|
}
|
|
|
|
const workflowWithPermissions = await addPermissionsToWorkflow(workflow, ctx.user.id);
|
|
return {
|
|
workflow: workflowWithPermissions,
|
|
};
|
|
};
|