* fix: break circular dependency in messageDispatcher via dependency injection Break the 4-file circular dependency chain: credit-service → reminderScheduler → smsReminderManager → messageDispatcher → credit-service Solution: - Add optional creditCheckFn parameter to messageDispatcher functions - Thread creditCheckFn through the call chain: scheduleWorkflowReminders → scheduleSMSReminder/scheduleWhatsappReminder → messageDispatcher - When creditCheckFn is provided, use it; otherwise fall back to dynamic CreditService import for backward compatibility - This breaks the workflows → billing import while preserving immediate fallback behavior Changes: - messageDispatcher: Accept optional creditCheckFn parameter, use it if provided - smsReminderManager: Thread creditCheckFn through scheduleSMSReminder - whatsappReminderManager: Thread creditCheckFn through scheduleWhatsappReminder - reminderScheduler: Add creditCheckFn to ScheduleWorkflowRemindersArgs and pass through processWorkflowStep All type checks, lint checks, and unit tests pass. Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * feat: wire creditCheckFn from all callers to complete circular dependency fix - Add creditCheckFn parameter to WorkflowService.scheduleFormWorkflows - Wire creditCheckFn from all 10 entry points that call workflow scheduling: * formSubmissionUtils.ts (form submissions) * roundRobinManualReassignment.ts (round-robin reassignment) * triggerFormSubmittedNoEventWorkflow.ts (form workflow trigger) * handleBookingRequested.ts (booking requests) * RegularBookingService.ts (2 calls - payment initiated & new bookings) * handleSeats.ts (seated bookings) * handleConfirmation.ts (2 calls - confirmation & payment) * handleMarkNoShow.ts (no-show updates) * confirm.handler.ts (booking rejection) - Update test expectations to use expect.objectContaining() - Fix pre-existing lint warning in handleMarkNoShow.ts (any type) - This completes the messageDispatcher circular dependency fix by ensuring creditCheckFn is actually passed through the call chain, breaking the 4-file circular dependency at runtime Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix: use generic type with type guard in logFailedResults to fix type check error - Replace constrained type with generic type parameter - Add proper type guard for rejected promises - Fixes CI type check failure in handleMarkNoShow.ts:385 - Avoids 'any' type while accepting any fulfilled value shape Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * wip * wip * wip * revert * revert * feat: wire creditCheckFn from all remaining callers to eliminate fallbacks - Wire creditCheckFn in packages/sms/sms-manager.ts (can safely import CreditService) - Create makeHandler factory pattern for CRON endpoints (scheduleSMSReminders.ts, scheduleWhatsappReminders.ts) - Wire creditCheckFn from apps/web CRON routes to factories - Add warning log in messageDispatcher when fallback is used - Complete creditCheckFn wiring from all direct callers (activateEventType.handler.ts, util.ts) This eliminates all fallbacks to dynamic import except as a safety net for unforeseen call sites. The circular dependency (workflows ↔ billing) remains acceptable as discussed with user (Option C). Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * test: update formSubmissionUtils tests to expect creditCheckFn parameter The scheduleFormWorkflows function now receives creditCheckFn as a parameter. Updated test assertions to use expect.objectContaining() with creditCheckFn: expect.any(Function) to account for the new dependency injection parameter. Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * test: update sms-manager test to expect creditCheckFn parameter The sendSmsOrFallbackEmail function now receives creditCheckFn as a parameter. Updated test assertion to use expect.objectContaining() with creditCheckFn: expect.any(Function) to account for the new dependency injection parameter. Also removed teamId: undefined assertion as the key may be omitted entirely from the actual call. Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * feat: make creditCheckFn required to fully break circular dependency This commit completes the circular dependency fix by making creditCheckFn required throughout the call chain, eliminating the dynamic import fallback entirely. Changes: - Make creditCheckFn required in messageDispatcher functions (sendSmsOrFallbackEmail, scheduleSmsOrFallbackEmail) - Remove dynamic import fallback and warning log from messageDispatcher - Make creditCheckFn required in ScheduleTextReminderArgs (smsReminderManager) - Make creditCheckFn required in processWorkflowStep and ScheduleWorkflowRemindersArgs (reminderScheduler) - Add creditCheckFn to SendCancelledRemindersArgs and wire from handleCancelBooking The circular dependency is now fully broken - no more dynamic imports of CreditService from within the workflows package. All callers must explicitly provide creditCheckFn via dependency injection. Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix: make creditCheckFn required in WorkflowService.scheduleFormWorkflows This commit fixes the CI type check error by making creditCheckFn required in WorkflowService.scheduleFormWorkflows. Previously, creditCheckFn was optional in scheduleFormWorkflows but required in scheduleWorkflowReminders, causing a type mismatch. Changes: - Make creditCheckFn required in scheduleFormWorkflows signature - Update WorkflowService.test.ts to pass mock creditCheckFn in all test cases - Add responseId and routedEventTypeId to test calls for completeness All callers of scheduleFormWorkflows already pass creditCheckFn, so this change is safe and completes the circular dependency fix. Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * remove * fix * refactor * refactor * refactor * wip * fix * fix * rm --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
623 lines
20 KiB
TypeScript
623 lines
20 KiB
TypeScript
import { cloneDeep } from "lodash";
|
|
|
|
import { enrichUserWithDelegationCredentialsIncludeServiceAccountKey } from "@calcom/app-store/delegationCredential";
|
|
import { eventTypeAppMetadataOptionalSchema } from "@calcom/app-store/zod-utils";
|
|
import dayjs from "@calcom/dayjs";
|
|
import {
|
|
sendRoundRobinReassignedEmailsAndSMS,
|
|
sendRoundRobinScheduledEmailsAndSMS,
|
|
sendRoundRobinUpdatedEmailsAndSMS,
|
|
} from "@calcom/emails/email-manager";
|
|
import EventManager from "@calcom/features/bookings/lib/EventManager";
|
|
import { getAllCredentialsIncludeServiceAccountKey } from "@calcom/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials";
|
|
import { getBookingResponsesPartialSchema } from "@calcom/features/bookings/lib/getBookingResponsesSchema";
|
|
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
|
|
import { getEventTypesFromDB } from "@calcom/features/bookings/lib/handleNewBooking/getEventTypesFromDB";
|
|
import { CreditService } from "@calcom/features/ee/billing/credit-service";
|
|
import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer";
|
|
import AssignmentReasonRecorder, {
|
|
RRReassignmentType,
|
|
} from "@calcom/features/ee/round-robin/assignmentReason/AssignmentReasonRecorder";
|
|
import { BookingLocationService } from "@calcom/features/ee/round-robin/lib/bookingLocationService";
|
|
import {
|
|
scheduleEmailReminder,
|
|
deleteScheduledEmailReminder,
|
|
} from "@calcom/features/ee/workflows/lib/reminders/emailReminderManager";
|
|
import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
|
|
import { getEventName } from "@calcom/features/eventtypes/lib/eventNaming";
|
|
import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser";
|
|
import { SENDER_NAME } from "@calcom/lib/constants";
|
|
import { IdempotencyKeyService } from "@calcom/lib/idempotencyKey/idempotencyKeyService";
|
|
import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj";
|
|
import logger from "@calcom/lib/logger";
|
|
import { getTranslation } from "@calcom/lib/server/i18n";
|
|
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
|
|
import { prisma } from "@calcom/prisma";
|
|
import { WorkflowActions, WorkflowMethods, WorkflowTriggerEvents } from "@calcom/prisma/enums";
|
|
import type { EventTypeMetadata, PlatformClientParams } from "@calcom/prisma/zod-utils";
|
|
import type { CalendarEvent } from "@calcom/types/Calendar";
|
|
|
|
import { handleRescheduleEventManager } from "./handleRescheduleEventManager";
|
|
import type { BookingSelectResult } from "./utils/bookingSelect";
|
|
import { bookingSelect } from "./utils/bookingSelect";
|
|
import { getDestinationCalendar } from "./utils/getDestinationCalendar";
|
|
import { getTeamMembers } from "./utils/getTeamMembers";
|
|
|
|
enum ErrorCode {
|
|
InvalidRoundRobinHost = "invalid_round_robin_host",
|
|
UserIsFixed = "user_is_round_robin_fixed",
|
|
}
|
|
|
|
export const roundRobinManualReassignment = async ({
|
|
bookingId,
|
|
newUserId,
|
|
orgId,
|
|
reassignReason,
|
|
reassignedById,
|
|
emailsEnabled = true,
|
|
platformClientParams,
|
|
}: {
|
|
bookingId: number;
|
|
newUserId: number;
|
|
orgId: number | null;
|
|
reassignReason?: string;
|
|
reassignedById: number;
|
|
emailsEnabled?: boolean;
|
|
platformClientParams?: PlatformClientParams;
|
|
}) => {
|
|
const roundRobinReassignLogger = logger.getSubLogger({
|
|
prefix: ["roundRobinManualReassign", `${bookingId}`],
|
|
});
|
|
|
|
roundRobinReassignLogger.info(`User ${reassignedById} initiating manual reassignment to user ${newUserId}`);
|
|
|
|
let booking = await prisma.booking.findUnique({
|
|
where: { id: bookingId },
|
|
select: bookingSelect,
|
|
});
|
|
|
|
if (!booking || !booking.user) {
|
|
roundRobinReassignLogger.error(`Booking ${bookingId} not found or has no associated user`);
|
|
throw new Error("Booking not found or has no associated user");
|
|
}
|
|
|
|
const eventTypeId = booking.eventTypeId;
|
|
if (!eventTypeId) {
|
|
roundRobinReassignLogger.error(`Booking ${bookingId} does not have an event type id`);
|
|
throw new Error("Event type not found");
|
|
}
|
|
|
|
const eventType = await getEventTypesFromDB(eventTypeId);
|
|
if (!eventType) {
|
|
roundRobinReassignLogger.error(`Event type ${eventTypeId} not found`);
|
|
throw new Error("Event type not found");
|
|
}
|
|
|
|
if (eventType.hostGroups && eventType.hostGroups.length > 1) {
|
|
roundRobinReassignLogger.error(
|
|
`Event type ${eventTypeId} has more than one round robin group, reassignment is not allowed`
|
|
);
|
|
throw new Error("Reassignment not allowed with more than one round robin group");
|
|
}
|
|
|
|
const eventTypeHosts = eventType.hosts.length
|
|
? eventType.hosts
|
|
: eventType.users.map((user) => ({
|
|
user,
|
|
isFixed: false,
|
|
priority: 2,
|
|
weight: 100,
|
|
schedule: null,
|
|
createdAt: new Date(0), // use earliest possible date as fallback
|
|
groupId: null,
|
|
}));
|
|
|
|
const fixedHost = eventTypeHosts.find((host) => host.isFixed);
|
|
const currentRRHost = booking.attendees.find((attendee) =>
|
|
eventTypeHosts.some((host) => !host.isFixed && host.user.email === attendee.email)
|
|
);
|
|
const newUserHost = eventTypeHosts.find((host) => host.user.id === newUserId);
|
|
|
|
if (!newUserHost) {
|
|
throw new Error(ErrorCode.InvalidRoundRobinHost);
|
|
}
|
|
|
|
if (newUserHost.isFixed) {
|
|
throw new Error(ErrorCode.UserIsFixed);
|
|
}
|
|
|
|
const originalOrganizer = booking.user;
|
|
const hasOrganizerChanged = !fixedHost && booking.userId !== newUserId;
|
|
const newUser = newUserHost.user;
|
|
const newUserT = await getTranslation(newUser.locale || "en", "common");
|
|
const originalOrganizerT = await getTranslation(originalOrganizer.locale || "en", "common");
|
|
|
|
const roundRobinHosts = eventType.hosts.filter((host) => !host.isFixed);
|
|
|
|
const attendeeEmailsSet = new Set(booking.attendees.map((attendee) => attendee.email));
|
|
|
|
// Find the current round robin host assigned
|
|
const previousRRHost = (() => {
|
|
for (const host of roundRobinHosts) {
|
|
if (host.user.id === booking.userId) {
|
|
return host.user;
|
|
}
|
|
if (attendeeEmailsSet.has(host.user.email)) {
|
|
return host.user;
|
|
}
|
|
}
|
|
})();
|
|
|
|
const previousRRHostT = await getTranslation(previousRRHost?.locale || "en", "common");
|
|
let bookingLocation = booking.location;
|
|
let conferenceCredentialId: number | null = null;
|
|
|
|
const organizer = hasOrganizerChanged ? newUser : booking.user ?? newUser;
|
|
|
|
const teamMembers = await getTeamMembers({
|
|
eventTypeHosts,
|
|
attendees: booking.attendees,
|
|
organizer,
|
|
previousHost: previousRRHost || null,
|
|
reassignedHost: newUser,
|
|
});
|
|
|
|
if (hasOrganizerChanged) {
|
|
const bookingResponses = booking.responses;
|
|
const responseSchema = getBookingResponsesPartialSchema({
|
|
bookingFields: eventType.bookingFields,
|
|
view: "reschedule",
|
|
});
|
|
const responseSafeParse = await responseSchema.safeParseAsync(bookingResponses);
|
|
const responses = responseSafeParse.success ? responseSafeParse.data : undefined;
|
|
|
|
// Determine the location for the new host
|
|
const isManagedEventType = !!eventType.parentId;
|
|
const isTeamEventType = !!eventType.teamId;
|
|
|
|
const locationResult = BookingLocationService.getLocationForHost({
|
|
hostMetadata: newUser.metadata,
|
|
eventTypeLocations: eventType.locations,
|
|
isManagedEventType,
|
|
isTeamEventType,
|
|
});
|
|
|
|
bookingLocation = locationResult.bookingLocation;
|
|
if (locationResult.requiresActualLink) {
|
|
conferenceCredentialId = locationResult.conferenceCredentialId;
|
|
}
|
|
|
|
const newBookingTitle = getEventName({
|
|
attendeeName: responses?.name || "Nameless",
|
|
eventType: eventType.title,
|
|
eventName: eventType.eventName,
|
|
// we send on behalf of team if >1 round robin attendee | collective
|
|
teamName: teamMembers.length > 1 ? eventType.team?.name : null,
|
|
host: newUser.name || "Nameless",
|
|
location: bookingLocation || "integrations:daily",
|
|
bookingFields: { ...responses },
|
|
eventDuration: dayjs(booking.endTime).diff(booking.startTime, "minutes"),
|
|
t: newUserT,
|
|
});
|
|
|
|
booking = await prisma.booking.update({
|
|
where: { id: bookingId },
|
|
data: {
|
|
userId: newUserId,
|
|
title: newBookingTitle,
|
|
userPrimaryEmail: newUser.email,
|
|
reassignReason,
|
|
reassignById: reassignedById,
|
|
idempotencyKey: IdempotencyKeyService.generate({
|
|
startTime: booking.startTime,
|
|
endTime: booking.endTime,
|
|
userId: newUser.id,
|
|
reassignedById,
|
|
}),
|
|
},
|
|
select: bookingSelect,
|
|
});
|
|
|
|
await AssignmentReasonRecorder.roundRobinReassignment({
|
|
bookingId,
|
|
reassignReason,
|
|
reassignById: reassignedById,
|
|
reassignmentType: RRReassignmentType.MANUAL,
|
|
});
|
|
} else if (currentRRHost) {
|
|
// Update the round-robin host attendee
|
|
await prisma.attendee.update({
|
|
where: { id: currentRRHost.id },
|
|
data: {
|
|
name: newUser.name || "",
|
|
email: newUser.email,
|
|
timeZone: newUser.timeZone,
|
|
locale: newUser.locale,
|
|
},
|
|
});
|
|
}
|
|
|
|
// When organizer hasn't changed, still extract conferenceCredentialId from event type locations
|
|
if (!hasOrganizerChanged && bookingLocation) {
|
|
const { conferenceCredentialId: extractedCredentialId } =
|
|
BookingLocationService.getLocationDetailsFromType({
|
|
locationType: bookingLocation,
|
|
eventTypeLocations: eventType.locations || [],
|
|
});
|
|
if (extractedCredentialId) {
|
|
conferenceCredentialId = extractedCredentialId;
|
|
}
|
|
}
|
|
|
|
const destinationCalendar = await getDestinationCalendar({
|
|
eventType,
|
|
booking,
|
|
newUserId,
|
|
hasOrganizerChanged,
|
|
});
|
|
|
|
const organizerT = await getTranslation(organizer?.locale || "en", "common");
|
|
|
|
const attendeePromises = [];
|
|
for (const attendee of booking.attendees) {
|
|
if (
|
|
attendee.email === newUser.email ||
|
|
attendee.email === previousRRHost?.email ||
|
|
teamMembers.some((member) => member.email === attendee.email)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
attendeePromises.push(
|
|
getTranslation(attendee.locale ?? "en", "common").then((tAttendee) => ({
|
|
email: attendee.email,
|
|
name: attendee.name,
|
|
timeZone: attendee.timeZone,
|
|
language: { translate: tAttendee, locale: attendee.locale ?? "en" },
|
|
phoneNumber: attendee.phoneNumber || undefined,
|
|
}))
|
|
);
|
|
}
|
|
|
|
const attendeeList = await Promise.all(attendeePromises);
|
|
|
|
const evt: CalendarEvent = {
|
|
type: eventType.slug,
|
|
title: booking.title,
|
|
description: eventType.description,
|
|
startTime: dayjs(booking.startTime).utc().format(),
|
|
endTime: dayjs(booking.endTime).utc().format(),
|
|
organizer: {
|
|
email: organizer.email,
|
|
name: organizer.name || "",
|
|
timeZone: organizer.timeZone,
|
|
language: { translate: organizerT, locale: organizer.locale || "en" },
|
|
},
|
|
attendees: attendeeList,
|
|
uid: booking.uid,
|
|
iCalUID: booking.iCalUID,
|
|
destinationCalendar,
|
|
team: {
|
|
members: teamMembers,
|
|
name: eventType.team?.name || "",
|
|
id: eventType.team?.id || 0,
|
|
},
|
|
hideOrganizerEmail: eventType.hideOrganizerEmail,
|
|
customInputs: isPrismaObjOrUndefined(booking.customInputs),
|
|
...getCalEventResponses({
|
|
bookingFields: eventType.bookingFields ?? null,
|
|
booking,
|
|
}),
|
|
customReplyToEmail: eventType?.customReplyToEmail,
|
|
location: bookingLocation,
|
|
...(platformClientParams ? platformClientParams : {}),
|
|
conferenceCredentialId: conferenceCredentialId ?? undefined,
|
|
};
|
|
|
|
if (hasOrganizerChanged) {
|
|
// location might changed and will be new created in eventManager.create (organizer default location)
|
|
evt.videoCallData = undefined;
|
|
// To prevent "The requested identifier already exists" error while updating event, we need to remove iCalUID
|
|
evt.iCalUID = undefined;
|
|
}
|
|
|
|
const credentials = await prisma.credential.findMany({
|
|
where: { userId: newUser.id },
|
|
include: { user: { select: { email: true } } },
|
|
});
|
|
|
|
const newUserWithCredentials = await enrichUserWithDelegationCredentialsIncludeServiceAccountKey({
|
|
user: { ...newUser, credentials },
|
|
});
|
|
|
|
const previousHostDestinationCalendar = hasOrganizerChanged
|
|
? await prisma.destinationCalendar.findFirst({
|
|
where: { userId: originalOrganizer.id },
|
|
})
|
|
: null;
|
|
|
|
const apps = eventTypeAppMetadataOptionalSchema.parse(eventType?.metadata?.apps);
|
|
|
|
// remove the event and meeting using the old host's credentials
|
|
if (hasOrganizerChanged && originalOrganizer.id !== newUser.id) {
|
|
const previousHostCredentials = await getAllCredentialsIncludeServiceAccountKey(
|
|
originalOrganizer,
|
|
eventType
|
|
);
|
|
|
|
const originalHostEventManager = new EventManager(
|
|
{ ...originalOrganizer, credentials: previousHostCredentials },
|
|
apps
|
|
);
|
|
|
|
const deletionEvent: CalendarEvent = {
|
|
...evt,
|
|
organizer: {
|
|
id: originalOrganizer.id,
|
|
name: originalOrganizer.name || "",
|
|
email: originalOrganizer.email,
|
|
username: originalOrganizer.username || undefined,
|
|
timeZone: originalOrganizer.timeZone,
|
|
language: { translate: originalOrganizerT, locale: originalOrganizer.locale ?? "en" },
|
|
timeFormat: getTimeFormatStringFromUserTimeFormat(originalOrganizer.timeFormat),
|
|
},
|
|
destinationCalendar: previousHostDestinationCalendar ? [previousHostDestinationCalendar] : [],
|
|
title: booking.title,
|
|
};
|
|
|
|
await originalHostEventManager.deleteEventsAndMeetings({
|
|
event: deletionEvent,
|
|
bookingReferences: booking.references,
|
|
});
|
|
}
|
|
|
|
const { evtWithAdditionalInfo } = await handleRescheduleEventManager({
|
|
evt,
|
|
rescheduleUid: booking.uid,
|
|
newBookingId: undefined,
|
|
changedOrganizer: hasOrganizerChanged,
|
|
previousHostDestinationCalendar: previousHostDestinationCalendar ? [previousHostDestinationCalendar] : [],
|
|
initParams: {
|
|
user: newUserWithCredentials,
|
|
eventType,
|
|
},
|
|
bookingId,
|
|
bookingLocation,
|
|
bookingICalUID: booking.iCalUID,
|
|
bookingMetadata: booking.metadata,
|
|
});
|
|
|
|
const { cancellationReason: _cancellationReason, ...evtWithoutCancellationReason } = evtWithAdditionalInfo;
|
|
|
|
// Send emails
|
|
if (emailsEnabled) {
|
|
await sendRoundRobinScheduledEmailsAndSMS({
|
|
calEvent: evtWithoutCancellationReason,
|
|
members: [
|
|
{
|
|
...newUser,
|
|
name: newUser.name || "",
|
|
username: newUser.username || "",
|
|
timeFormat: getTimeFormatStringFromUserTimeFormat(newUser.timeFormat),
|
|
language: { translate: newUserT, locale: newUser.locale || "en" },
|
|
},
|
|
],
|
|
reassigned: {
|
|
name: newUser.name,
|
|
email: newUser.email,
|
|
reason: reassignReason,
|
|
byUser: originalOrganizer.name || undefined,
|
|
},
|
|
});
|
|
}
|
|
|
|
// Send cancellation email to previous RR host
|
|
const cancelledEvt = cloneDeep(evtWithAdditionalInfo);
|
|
cancelledEvt.organizer = {
|
|
email: originalOrganizer.email,
|
|
name: originalOrganizer.name || "",
|
|
timeZone: originalOrganizer.timeZone,
|
|
language: { translate: originalOrganizerT, locale: originalOrganizer.locale || "en" },
|
|
};
|
|
|
|
if (previousRRHost && emailsEnabled) {
|
|
await sendRoundRobinReassignedEmailsAndSMS({
|
|
calEvent: cancelledEvt,
|
|
members: [
|
|
{
|
|
...previousRRHost,
|
|
name: previousRRHost.name || "",
|
|
username: previousRRHost.username || "",
|
|
timeFormat: getTimeFormatStringFromUserTimeFormat(previousRRHost.timeFormat),
|
|
language: { translate: previousRRHostT, locale: previousRRHost.locale || "en" },
|
|
},
|
|
],
|
|
reassignedTo: { name: newUser.name, email: newUser.email },
|
|
eventTypeMetadata: eventType?.metadata as EventTypeMetadata,
|
|
});
|
|
}
|
|
|
|
if (hasOrganizerChanged) {
|
|
if (emailsEnabled && dayjs(evt.startTime).isAfter(dayjs())) {
|
|
// send email with event updates to attendees
|
|
await sendRoundRobinUpdatedEmailsAndSMS({
|
|
calEvent: evtWithoutCancellationReason,
|
|
eventTypeMetadata: eventType?.metadata as EventTypeMetadata,
|
|
});
|
|
}
|
|
|
|
// Handle changing workflows with organizer
|
|
await handleWorkflowsUpdate({
|
|
booking,
|
|
newUser,
|
|
evt: evtWithAdditionalInfo,
|
|
eventType,
|
|
orgId,
|
|
});
|
|
}
|
|
|
|
return booking;
|
|
};
|
|
|
|
export async function handleWorkflowsUpdate({
|
|
booking,
|
|
newUser,
|
|
evt,
|
|
eventType,
|
|
orgId,
|
|
}: {
|
|
booking: BookingSelectResult;
|
|
newUser: {
|
|
id: number;
|
|
email: string;
|
|
locale?: string | null;
|
|
};
|
|
evt: CalendarEvent;
|
|
eventType: Awaited<ReturnType<typeof getEventTypesFromDB>>;
|
|
orgId: number | null;
|
|
}) {
|
|
const workflowReminders = await prisma.workflowReminder.findMany({
|
|
where: {
|
|
bookingUid: booking.uid,
|
|
method: WorkflowMethods.EMAIL,
|
|
scheduled: true,
|
|
OR: [{ cancelled: false }, { cancelled: null }],
|
|
workflowStep: {
|
|
action: WorkflowActions.EMAIL_HOST,
|
|
workflow: {
|
|
trigger: {
|
|
in: [
|
|
WorkflowTriggerEvents.BEFORE_EVENT,
|
|
WorkflowTriggerEvents.NEW_EVENT,
|
|
WorkflowTriggerEvents.AFTER_EVENT,
|
|
],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
select: {
|
|
id: true,
|
|
referenceId: true,
|
|
workflowStep: {
|
|
select: {
|
|
id: true,
|
|
template: true,
|
|
workflow: {
|
|
select: {
|
|
userId: true,
|
|
teamId: true,
|
|
trigger: true,
|
|
time: true,
|
|
timeUnit: true,
|
|
},
|
|
},
|
|
emailSubject: true,
|
|
reminderBody: true,
|
|
sender: true,
|
|
includeCalendarEvent: true,
|
|
verifiedAt: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const workflowEventMetadata = { videoCallUrl: getVideoCallUrlFromCalEvent(evt) };
|
|
const bookerUrl = await getBookerBaseUrl(orgId);
|
|
|
|
for (const workflowReminder of workflowReminders) {
|
|
const workflowStep = workflowReminder.workflowStep;
|
|
|
|
if (workflowStep) {
|
|
const workflow = workflowStep.workflow;
|
|
await scheduleEmailReminder({
|
|
evt: {
|
|
...evt,
|
|
metadata: workflowEventMetadata,
|
|
eventType,
|
|
bookerUrl,
|
|
},
|
|
action: WorkflowActions.EMAIL_HOST,
|
|
triggerEvent: workflow.trigger,
|
|
timeSpan: {
|
|
time: workflow.time,
|
|
timeUnit: workflow.timeUnit,
|
|
},
|
|
sendTo: [newUser.email],
|
|
template: workflowStep.template,
|
|
emailSubject: workflowStep.emailSubject || undefined,
|
|
emailBody: workflowStep.reminderBody || undefined,
|
|
sender: workflowStep.sender || SENDER_NAME,
|
|
hideBranding: true,
|
|
includeCalendarEvent: workflowStep.includeCalendarEvent,
|
|
workflowStepId: workflowStep.id,
|
|
verifiedAt: workflowStep.verifiedAt,
|
|
});
|
|
}
|
|
|
|
await deleteScheduledEmailReminder(workflowReminder.id);
|
|
}
|
|
|
|
// Send new event workflows to new organizer
|
|
const newEventWorkflows = await prisma.workflow.findMany({
|
|
where: {
|
|
trigger: WorkflowTriggerEvents.NEW_EVENT,
|
|
OR: [
|
|
{
|
|
isActiveOnAll: true,
|
|
teamId: eventType?.teamId,
|
|
},
|
|
{
|
|
activeOn: {
|
|
some: {
|
|
eventTypeId: eventType.id,
|
|
},
|
|
},
|
|
},
|
|
...(eventType?.teamId
|
|
? [
|
|
{
|
|
activeOnTeams: {
|
|
some: {
|
|
teamId: eventType.teamId,
|
|
},
|
|
},
|
|
},
|
|
]
|
|
: []),
|
|
...(eventType?.team?.parentId
|
|
? [
|
|
{
|
|
isActiveOnAll: true,
|
|
teamId: eventType.team.parentId,
|
|
},
|
|
]
|
|
: []),
|
|
],
|
|
},
|
|
include: {
|
|
steps: {
|
|
where: {
|
|
action: WorkflowActions.EMAIL_HOST,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const creditService = new CreditService();
|
|
|
|
await scheduleWorkflowReminders({
|
|
workflows: newEventWorkflows,
|
|
smsReminderNumber: null,
|
|
calendarEvent: {
|
|
...evt,
|
|
metadata: workflowEventMetadata,
|
|
eventType: { slug: eventType.slug },
|
|
bookerUrl,
|
|
},
|
|
hideBranding: !!eventType?.owner?.hideBranding,
|
|
creditCheckFn: creditService.hasAvailableCredits.bind(creditService),
|
|
});
|
|
}
|
|
|
|
export default roundRobinManualReassignment;
|