3065 lines
106 KiB
TypeScript
3065 lines
106 KiB
TypeScript
import process from "node:process";
|
|
import processExternalId from "@calcom/app-store/_utils/calendars/processExternalId";
|
|
import { getPaymentAppData } from "@calcom/app-store/_utils/payments/getPaymentAppData";
|
|
import {
|
|
enrichHostsWithDelegationCredentials,
|
|
getFirstDelegationConferencingCredentialAppLocation,
|
|
} from "@calcom/app-store/delegationCredential";
|
|
import { metadata as GoogleMeetMetadata } from "@calcom/app-store/googlevideo/_metadata";
|
|
import {
|
|
getLocationValueForDB,
|
|
MeetLocationType,
|
|
OrganizerDefaultConferencingAppType,
|
|
} from "@calcom/app-store/locations";
|
|
import { getAppFromSlug } from "@calcom/app-store/utils";
|
|
import {
|
|
eventTypeAppMetadataOptionalSchema,
|
|
eventTypeMetaDataSchemaWithTypedApps,
|
|
} from "@calcom/app-store/zod-utils";
|
|
import dayjs from "@calcom/dayjs";
|
|
import { scheduleMandatoryReminder } from "@calcom/ee/workflows/lib/reminders/scheduleMandatoryReminder";
|
|
import getICalUID from "@calcom/emails/lib/getICalUID";
|
|
import { verifyCodeUnAuthenticated } from "@calcom/features/auth/lib/verifyCodeUnAuthenticated";
|
|
import type { ActionSource } from "@calcom/features/booking-audit/lib/types/actionSource";
|
|
import type {
|
|
BookingDataSchemaGetter,
|
|
BookingHandlerInput,
|
|
CreateBookingMeta,
|
|
CreateRegularBookingData,
|
|
} from "@calcom/features/bookings/lib/dto/types";
|
|
import EventManager, { placeholderCreatedEvent } from "@calcom/features/bookings/lib/EventManager";
|
|
import { getAssignmentReasonCategory } from "@calcom/features/bookings/lib/getAssignmentReasonCategory";
|
|
import type { CheckBookingAndDurationLimitsService } from "@calcom/features/bookings/lib/handleNewBooking/checkBookingAndDurationLimits";
|
|
import { handlePayment } from "@calcom/features/bookings/lib/handlePayment";
|
|
import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhookTrigger";
|
|
import { isEventTypeLoggingEnabled } from "@calcom/features/bookings/lib/isEventTypeLoggingEnabled";
|
|
import type { BookingEventHandlerService } from "@calcom/features/bookings/lib/onBookingEvents/BookingEventHandlerService";
|
|
import type { BookingRescheduledPayload } from "@calcom/features/bookings/lib/onBookingEvents/types.d";
|
|
import type { BookingEmailAndSmsTasker } from "@calcom/features/bookings/lib/tasker/BookingEmailAndSmsTasker";
|
|
import { CalendarEventBuilder } from "@calcom/features/CalendarEventBuilder";
|
|
import { getSpamCheckService } from "@calcom/features/di/watchlist/containers/SpamCheckService.container";
|
|
import { CreditService } from "@calcom/features/ee/billing/credit-service";
|
|
import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer";
|
|
import AssignmentReasonRecorder from "@calcom/features/ee/round-robin/assignmentReason/AssignmentReasonRecorder";
|
|
import { BookingLocationService } from "@calcom/features/ee/round-robin/lib/bookingLocationService";
|
|
import { getAllWorkflowsFromEventType } from "@calcom/features/ee/workflows/lib/getAllWorkflowsFromEventType";
|
|
import { WorkflowService } from "@calcom/features/ee/workflows/lib/service/WorkflowService";
|
|
import { WorkflowRepository } from "@calcom/features/ee/workflows/repositories/WorkflowRepository";
|
|
import { getUsernameList } from "@calcom/features/eventtypes/lib/defaultEvents";
|
|
import { getEventName, updateHostInEventName } from "@calcom/features/eventtypes/lib/eventNaming";
|
|
import type { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
|
import { getFullName } from "@calcom/features/form-builder/utils";
|
|
import type { HashedLinkService } from "@calcom/features/hashedLink/lib/service/HashedLinkService";
|
|
import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository";
|
|
import { getRoutingTraceService } from "@calcom/features/routing-trace/di/RoutingTraceService.container";
|
|
import { handleAnalyticsEvents } from "@calcom/features/tasker/tasks/analytics/handleAnalyticsEvents";
|
|
import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
|
import { UsersRepository } from "@calcom/features/users/users.repository";
|
|
import type { GetSubscriberOptions } from "@calcom/features/webhooks/lib/getWebhooks";
|
|
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
|
|
import type { IWebhookProducerService } from "@calcom/features/webhooks/lib/interface/WebhookProducerService";
|
|
import {
|
|
cancelNoShowTasksForBooking,
|
|
deleteWebhookScheduledTriggers,
|
|
scheduleTrigger,
|
|
} from "@calcom/features/webhooks/lib/scheduleTrigger";
|
|
import type { EventPayloadType, EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload";
|
|
import { groupHostsByGroupId } from "@calcom/lib/bookings/hostGroupUtils";
|
|
import { shouldIgnoreContactOwner } from "@calcom/lib/bookings/routing/utils";
|
|
import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser";
|
|
import { DEFAULT_GROUP_ID, ENABLE_ASYNC_TASKER } from "@calcom/lib/constants";
|
|
import { ErrorCode } from "@calcom/lib/errorCodes";
|
|
import { ErrorWithCode } from "@calcom/lib/errors";
|
|
import { extractBaseEmail } from "@calcom/lib/extract-base-email";
|
|
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
|
|
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
|
|
import { HttpError } from "@calcom/lib/http-error";
|
|
import { criticalLogger } from "@calcom/lib/logger.server";
|
|
import { getPiiFreeCalendarEvent, getPiiFreeEventType } from "@calcom/lib/piiFreeData";
|
|
import { safeStringify } from "@calcom/lib/safeStringify";
|
|
import { getServerErrorFromUnknown } from "@calcom/lib/server/getServerErrorFromUnknown";
|
|
import { getTranslation } from "@calcom/i18n/server";
|
|
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
|
|
import { distributedTracing } from "@calcom/lib/tracing/factory";
|
|
import type { PrismaClient } from "@calcom/prisma";
|
|
import type { AssignmentReasonEnum, DestinationCalendar, Prisma, User } from "@calcom/prisma/client";
|
|
import {
|
|
BookingStatus,
|
|
CreationSource,
|
|
SchedulingType,
|
|
WebhookTriggerEvents,
|
|
WorkflowTriggerEvents,
|
|
} from "@calcom/prisma/enums";
|
|
import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils";
|
|
import type {
|
|
AdditionalInformation,
|
|
AppsStatus,
|
|
CalEventResponses,
|
|
CalendarEvent,
|
|
} from "@calcom/types/Calendar";
|
|
import type { CredentialForCalendarService } from "@calcom/types/Credential";
|
|
import type { EventResult, PartialReference } from "@calcom/types/EventManager";
|
|
import short, { uuid } from "short-uuid";
|
|
import { v5 as uuidv5 } from "uuid";
|
|
import type { BookingRepository } from "../../repositories/BookingRepository";
|
|
import { BookingActionMap, type BookingActionType, BookingEmailSmsHandler } from "../BookingEmailSmsHandler";
|
|
import { getAllCredentialsIncludeServiceAccountKey } from "../getAllCredentialsForUsersOnEvent/getAllCredentials";
|
|
import { refreshCredentials } from "../getAllCredentialsForUsersOnEvent/refreshCredentials";
|
|
import getBookingDataSchema from "../getBookingDataSchema";
|
|
import type { LuckyUserService } from "../getLuckyUser";
|
|
import { addVideoCallDataToEvent } from "../handleNewBooking/addVideoCallDataToEvent";
|
|
import {
|
|
buildBookingCreatedAuditData,
|
|
buildBookingRescheduledAuditData,
|
|
} from "../handleNewBooking/buildBookingEventAuditData";
|
|
import { checkActiveBookingsLimitForBooker } from "../handleNewBooking/checkActiveBookingsLimitForBooker";
|
|
import { checkIfBookerEmailIsBlocked } from "../handleNewBooking/checkIfBookerEmailIsBlocked";
|
|
import type { Booking } from "../handleNewBooking/createBooking";
|
|
import { createBooking } from "../handleNewBooking/createBooking";
|
|
import { ensureAvailableUsers } from "../handleNewBooking/ensureAvailableUsers";
|
|
import { getAuditActionSource } from "../handleNewBooking/getAuditActionSource";
|
|
import { getBookingAuditActorForNewBooking } from "../handleNewBooking/getBookingAuditActorForNewBooking";
|
|
import { getBookingData } from "../handleNewBooking/getBookingData";
|
|
import { getCustomInputsResponses } from "../handleNewBooking/getCustomInputsResponses";
|
|
import { getEventType } from "../handleNewBooking/getEventType";
|
|
import type { getEventTypeResponse } from "../handleNewBooking/getEventTypesFromDB";
|
|
import { getLocationValuesForDb } from "../handleNewBooking/getLocationValuesForDb";
|
|
import { getRequiresConfirmationFlags } from "../handleNewBooking/getRequiresConfirmationFlags";
|
|
import { getSeatedBooking } from "../handleNewBooking/getSeatedBooking";
|
|
import { getVideoCallDetails } from "../handleNewBooking/getVideoCallDetails";
|
|
import { handleAppsStatus } from "../handleNewBooking/handleAppsStatus";
|
|
import { loadAndValidateUsers } from "../handleNewBooking/loadAndValidateUsers";
|
|
import type { BookingType } from "../handleNewBooking/originalRescheduledBookingUtils";
|
|
import { getOriginalRescheduledBooking } from "../handleNewBooking/originalRescheduledBookingUtils";
|
|
import { scheduleNoShowTriggers } from "../handleNewBooking/scheduleNoShowTriggers";
|
|
import type { IEventTypePaymentCredentialType, Invitee, IsFixedAwareUser } from "../handleNewBooking/types";
|
|
import { validateBookingTimeIsNotOutOfBounds } from "../handleNewBooking/validateBookingTimeIsNotOutOfBounds";
|
|
import { validateEventLength } from "../handleNewBooking/validateEventLength";
|
|
import handleSeats from "../handleSeats/handleSeats";
|
|
import type { IBookingService } from "../interfaces/IBookingService";
|
|
import { isWithinMinimumRescheduleNotice } from "../reschedule/isWithinMinimumRescheduleNotice";
|
|
|
|
const translator = short();
|
|
|
|
type IsFixedAwareUserWithCredentials = Omit<IsFixedAwareUser, "credentials"> & {
|
|
credentials: CredentialForCalendarService[];
|
|
};
|
|
|
|
function assertNonEmptyArray<T>(arr: T[]): asserts arr is [T, ...T[]] {
|
|
if (arr.length === 0) {
|
|
throw new Error("Array should have at least one item, but it's empty");
|
|
}
|
|
}
|
|
|
|
function getICalSequence(originalRescheduledBooking: BookingType | null) {
|
|
// If new booking set the sequence to 0
|
|
if (!originalRescheduledBooking) {
|
|
return 0;
|
|
}
|
|
|
|
// If rescheduling and there is no sequence set, assume sequence should be 1
|
|
if (!originalRescheduledBooking.iCalSequence) {
|
|
return 1;
|
|
}
|
|
|
|
// If rescheduling then increment sequence by 1
|
|
return originalRescheduledBooking.iCalSequence + 1;
|
|
}
|
|
|
|
type CreatedBooking = Booking & {
|
|
isShortCircuitedBooking?: boolean;
|
|
} & { appsStatus?: AppsStatus[]; paymentUid?: string; paymentId?: number };
|
|
type ReturnTypeCreateBooking = Awaited<ReturnType<typeof createBooking>>;
|
|
export const buildDryRunBooking = ({
|
|
eventTypeId,
|
|
organizerUser,
|
|
eventName,
|
|
startTime,
|
|
endTime,
|
|
contactOwnerFromReq,
|
|
contactOwnerEmail,
|
|
allHostUsers,
|
|
isManagedEventType,
|
|
}: {
|
|
eventTypeId: number;
|
|
organizerUser: {
|
|
id: number;
|
|
uuid: string;
|
|
name: string | null;
|
|
username: string | null;
|
|
email: string;
|
|
timeZone: string;
|
|
isPlatformManaged?: boolean;
|
|
};
|
|
eventName: string;
|
|
startTime: string;
|
|
endTime: string;
|
|
contactOwnerFromReq: string | null;
|
|
contactOwnerEmail: string | null;
|
|
allHostUsers: { id: number }[];
|
|
isManagedEventType: boolean;
|
|
}) => {
|
|
const sanitizedOrganizerUser = {
|
|
id: organizerUser.id,
|
|
uuid: organizerUser.uuid,
|
|
name: organizerUser.name,
|
|
username: organizerUser.username,
|
|
email: organizerUser.email,
|
|
timeZone: organizerUser.timeZone,
|
|
isPlatformManaged: organizerUser.isPlatformManaged ?? false,
|
|
};
|
|
const booking = {
|
|
id: -101,
|
|
uid: "DRY_RUN_UID",
|
|
iCalUID: "DRY_RUN_ICAL_UID",
|
|
status: BookingStatus.ACCEPTED,
|
|
eventTypeId: eventTypeId,
|
|
user: sanitizedOrganizerUser,
|
|
userId: sanitizedOrganizerUser.id,
|
|
userUuid: sanitizedOrganizerUser.uuid,
|
|
title: eventName,
|
|
startTime: new Date(startTime),
|
|
endTime: new Date(endTime),
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
attendees: [],
|
|
oneTimePassword: null,
|
|
smsReminderNumber: null,
|
|
metadata: {},
|
|
idempotencyKey: null,
|
|
userPrimaryEmail: null,
|
|
description: null,
|
|
customInputs: null,
|
|
responses: null,
|
|
location: null,
|
|
paid: false,
|
|
cancellationReason: null,
|
|
rejectionReason: null,
|
|
dynamicEventSlugRef: null,
|
|
dynamicGroupSlugRef: null,
|
|
fromReschedule: null,
|
|
recurringEventId: null,
|
|
scheduledJobs: [],
|
|
rescheduledBy: null,
|
|
destinationCalendarId: null,
|
|
reassignReason: null,
|
|
reassignById: null,
|
|
rescheduled: false,
|
|
isRecorded: false,
|
|
iCalSequence: 0,
|
|
rating: null,
|
|
ratingFeedback: null,
|
|
noShowHost: null,
|
|
cancelledBy: null,
|
|
creationSource: CreationSource.WEBAPP,
|
|
references: [],
|
|
payment: [],
|
|
} satisfies ReturnTypeCreateBooking;
|
|
|
|
/**
|
|
* Troubleshooting data
|
|
*/
|
|
const troubleshooterData = {
|
|
organizerUserId: organizerUser.id,
|
|
eventTypeId,
|
|
askedContactOwnerEmail: contactOwnerFromReq,
|
|
usedContactOwnerEmail: contactOwnerEmail,
|
|
allHostUsers: allHostUsers.map((user) => user.id),
|
|
isManagedEventType: isManagedEventType,
|
|
};
|
|
|
|
return {
|
|
booking,
|
|
troubleshooterData,
|
|
};
|
|
};
|
|
|
|
const buildDryRunEventManager = () => {
|
|
return {
|
|
create: async () => ({ results: [], referencesToCreate: [] }),
|
|
reschedule: async () => ({ results: [], referencesToCreate: [] }),
|
|
};
|
|
};
|
|
|
|
export const buildEventForTeamEventType = async ({
|
|
existingEvent: evt,
|
|
users,
|
|
organizerUser,
|
|
schedulingType,
|
|
team,
|
|
}: {
|
|
existingEvent: Partial<CalendarEvent>;
|
|
users: (Pick<User, "id" | "name" | "timeZone" | "locale" | "email"> & {
|
|
destinationCalendar: DestinationCalendar | null;
|
|
isFixed?: boolean;
|
|
})[];
|
|
organizerUser: { email: string };
|
|
schedulingType: SchedulingType | null;
|
|
team?: {
|
|
id: number;
|
|
name: string;
|
|
} | null;
|
|
}) => {
|
|
// not null assertion.
|
|
if (!schedulingType) {
|
|
throw new Error("Scheduling type is required for team event type");
|
|
}
|
|
const teamDestinationCalendars: DestinationCalendar[] = [];
|
|
const fixedUsers = users.filter((user) => user.isFixed);
|
|
const nonFixedUsers = users.filter((user) => !user.isFixed);
|
|
const filteredUsers =
|
|
schedulingType === SchedulingType.ROUND_ROBIN ? [...fixedUsers, ...nonFixedUsers] : users;
|
|
|
|
// Organizer or user owner of this event type it's not listed as a team member.
|
|
const teamMemberPromises = filteredUsers
|
|
.filter((user) => user.email !== organizerUser.email)
|
|
.map(async (user) => {
|
|
// TODO: Add back once EventManager tests are ready https://github.com/calcom/cal.com/pull/14610#discussion_r1567817120
|
|
// push to teamDestinationCalendars if it's a team event but collective only
|
|
if (schedulingType === "COLLECTIVE" && user.destinationCalendar) {
|
|
teamDestinationCalendars.push({
|
|
...user.destinationCalendar,
|
|
externalId: processExternalId(user.destinationCalendar),
|
|
});
|
|
}
|
|
|
|
return {
|
|
id: user.id,
|
|
email: user.email ?? "",
|
|
name: user.name ?? "",
|
|
firstName: "",
|
|
lastName: "",
|
|
timeZone: user.timeZone,
|
|
language: {
|
|
translate: await getTranslation(user.locale ?? "en", "common"),
|
|
locale: user.locale ?? "en",
|
|
},
|
|
};
|
|
});
|
|
|
|
const teamMembers = await Promise.all(teamMemberPromises);
|
|
|
|
const updatedEvt = CalendarEventBuilder.fromEvent(evt)
|
|
?.withDestinationCalendar([...(evt.destinationCalendar ?? []), ...teamDestinationCalendars])
|
|
.build();
|
|
|
|
if (!updatedEvt) {
|
|
throw new HttpError({
|
|
statusCode: 400,
|
|
message: "Failed to build event with destination calendar due to missing required fields",
|
|
});
|
|
}
|
|
|
|
evt = updatedEvt;
|
|
|
|
const teamEvt = CalendarEventBuilder.fromEvent(evt)
|
|
?.withTeam({
|
|
members: teamMembers,
|
|
name: team?.name || "Nameless",
|
|
id: team?.id ?? 0,
|
|
})
|
|
.build();
|
|
|
|
if (!teamEvt) {
|
|
throw new HttpError({
|
|
statusCode: 400,
|
|
message: "Failed to build team event due to missing required fields",
|
|
});
|
|
}
|
|
|
|
return teamEvt;
|
|
};
|
|
|
|
function buildTroubleshooterData({
|
|
eventType,
|
|
}: {
|
|
eventType: {
|
|
id: number;
|
|
slug: string;
|
|
};
|
|
}) {
|
|
const troubleshooterData: {
|
|
organizerUser: {
|
|
id: number;
|
|
} | null;
|
|
eventType: {
|
|
id: number;
|
|
slug: string;
|
|
};
|
|
allHostUsers: number[];
|
|
luckyUsers: number[];
|
|
luckyUserPool: number[];
|
|
fixedUsers: number[];
|
|
luckyUsersFromFirstBooking: number[];
|
|
usedContactOwnerEmail: string | null;
|
|
askedContactOwnerEmail: string | null;
|
|
isManagedEventType: boolean;
|
|
} = {
|
|
organizerUser: null,
|
|
eventType: {
|
|
id: eventType.id,
|
|
slug: eventType.slug,
|
|
},
|
|
luckyUsers: [],
|
|
luckyUserPool: [],
|
|
fixedUsers: [],
|
|
luckyUsersFromFirstBooking: [],
|
|
usedContactOwnerEmail: null,
|
|
allHostUsers: [],
|
|
askedContactOwnerEmail: null,
|
|
isManagedEventType: false,
|
|
};
|
|
return troubleshooterData;
|
|
}
|
|
|
|
function formatAvailabilitySnapshot(data: {
|
|
dateRanges: { start: dayjs.Dayjs; end: dayjs.Dayjs }[];
|
|
oooExcludedDateRanges: { start: dayjs.Dayjs; end: dayjs.Dayjs }[];
|
|
}) {
|
|
return {
|
|
...data,
|
|
dateRanges: data.dateRanges.map(({ start, end }) => ({
|
|
start: start.toISOString(),
|
|
end: end.toISOString(),
|
|
})),
|
|
oooExcludedDateRanges: data.oooExcludedDateRanges.map(({ start, end }) => ({
|
|
start: start.toISOString(),
|
|
end: end.toISOString(),
|
|
})),
|
|
};
|
|
}
|
|
|
|
function buildBookingCreatedPayload({
|
|
booking,
|
|
organizerUserId,
|
|
organizerUserUuid,
|
|
hashedLink,
|
|
isDryRun,
|
|
organizationId,
|
|
}: {
|
|
booking: {
|
|
id: number;
|
|
uid: string;
|
|
startTime: Date;
|
|
endTime: Date;
|
|
status: BookingStatus;
|
|
userId: number | null;
|
|
};
|
|
organizerUserId: number;
|
|
organizerUserUuid: string | null;
|
|
hashedLink: string | null;
|
|
isDryRun: boolean;
|
|
organizationId: number | null;
|
|
}) {
|
|
return {
|
|
config: {
|
|
isDryRun,
|
|
},
|
|
bookingFormData: {
|
|
hashedLink,
|
|
},
|
|
booking: {
|
|
id: booking.id,
|
|
uid: booking.uid,
|
|
startTime: booking.startTime,
|
|
endTime: booking.endTime,
|
|
status: booking.status,
|
|
userId: booking.userId,
|
|
userUuid: organizerUserUuid,
|
|
user: {
|
|
id: organizerUserId,
|
|
},
|
|
},
|
|
organizationId,
|
|
};
|
|
}
|
|
|
|
export interface IBookingServiceDependencies {
|
|
checkBookingAndDurationLimitsService: CheckBookingAndDurationLimitsService;
|
|
prismaClient: PrismaClient;
|
|
bookingRepository: BookingRepository;
|
|
luckyUserService: LuckyUserService;
|
|
userRepository: UserRepository;
|
|
hashedLinkService: HashedLinkService;
|
|
bookingEmailAndSmsTasker: BookingEmailAndSmsTasker;
|
|
featuresRepository: FeaturesRepository;
|
|
bookingEventHandler: BookingEventHandlerService;
|
|
webhookProducer: IWebhookProducerService;
|
|
}
|
|
|
|
async function validateRescheduleRestrictions({
|
|
rescheduleUid,
|
|
userId,
|
|
eventType,
|
|
}: {
|
|
rescheduleUid: string | null | undefined;
|
|
userId: number | null;
|
|
eventType: { seatsPerTimeSlot: number | null; minimumRescheduleNotice: number | null } | null;
|
|
}): Promise<void> {
|
|
if (!rescheduleUid || !eventType) {
|
|
return; // Not a reschedule, skip validation
|
|
}
|
|
|
|
const bookingSeat = rescheduleUid ? await getSeatedBooking(rescheduleUid) : null;
|
|
const actualRescheduleUid = bookingSeat ? bookingSeat.booking.uid : rescheduleUid;
|
|
|
|
if (!actualRescheduleUid) {
|
|
return; // No valid reschedule UID
|
|
}
|
|
|
|
try {
|
|
const originalRescheduledBooking = await getOriginalRescheduledBooking(
|
|
actualRescheduleUid,
|
|
!!eventType.seatsPerTimeSlot
|
|
);
|
|
|
|
// Check if user is the organizer
|
|
const isUserOrganizer =
|
|
userId && originalRescheduledBooking.userId && userId === originalRescheduledBooking.userId;
|
|
|
|
// Check minimum reschedule notice (only for non-organizers)
|
|
const { minimumRescheduleNotice } = originalRescheduledBooking.eventType || {};
|
|
if (
|
|
!isUserOrganizer &&
|
|
isWithinMinimumRescheduleNotice(originalRescheduledBooking.startTime, minimumRescheduleNotice ?? null)
|
|
) {
|
|
throw new HttpError({
|
|
statusCode: 403,
|
|
message: "Rescheduling is not allowed within the minimum notice period before the event",
|
|
});
|
|
}
|
|
} catch (error) {
|
|
// Re-throw HttpError (including our 403 validation error)
|
|
if (error instanceof HttpError) {
|
|
throw error;
|
|
}
|
|
// For other errors (like booking not found), let the service handle it later
|
|
// We don't want to fail early validation for these cases
|
|
}
|
|
}
|
|
|
|
/**
|
|
* TODO: Ideally we should send organizationId directly to handleNewBooking.
|
|
* webapp can derive from domain and API V2 knows it already through its endpoint URL
|
|
*/
|
|
async function getEventOrganizationId({
|
|
eventType,
|
|
}: {
|
|
eventType: {
|
|
userId: number | null;
|
|
team: {
|
|
parentId: number | null;
|
|
} | null;
|
|
parent: {
|
|
team: {
|
|
parentId: number | null;
|
|
} | null;
|
|
} | null;
|
|
};
|
|
}) {
|
|
let eventOrganizationId: number | null = null;
|
|
const team = eventType.team ?? eventType.parent?.team ?? null;
|
|
eventOrganizationId = team?.parentId ?? null;
|
|
|
|
if (eventOrganizationId) {
|
|
return eventOrganizationId;
|
|
}
|
|
|
|
if (eventType.userId) {
|
|
// TODO: Moving it to instance based access through DI in a followup
|
|
const profile = await ProfileRepository.findFirstForUserId({
|
|
userId: eventType.userId,
|
|
});
|
|
eventOrganizationId = profile?.organizationId ?? null;
|
|
return eventOrganizationId;
|
|
}
|
|
|
|
return eventOrganizationId;
|
|
}
|
|
|
|
async function handler(
|
|
this: RegularBookingService,
|
|
input: BookingHandlerInput,
|
|
deps: IBookingServiceDependencies,
|
|
bookingDataSchemaGetter: BookingDataSchemaGetter = getBookingDataSchema
|
|
) {
|
|
const {
|
|
bookingData: rawBookingData,
|
|
userId,
|
|
userUuid,
|
|
platformClientId,
|
|
platformCancelUrl,
|
|
platformBookingUrl,
|
|
platformRescheduleUrl,
|
|
platformBookingLocation,
|
|
hostname,
|
|
forcedSlug,
|
|
areCalendarEventsEnabled = true,
|
|
skipAvailabilityCheck = false,
|
|
skipEventLimitsCheck = false,
|
|
skipCalendarSyncTaskCreation = false,
|
|
traceContext: passedTraceContext,
|
|
} = input;
|
|
let bookingEmailsAndSmsTaskerAction: BookingActionType = BookingActionMap.requested;
|
|
|
|
const traceContext = passedTraceContext
|
|
? passedTraceContext
|
|
: distributedTracing.createTrace("booking_creation");
|
|
|
|
const tracingLogger = distributedTracing.getTracingLogger(traceContext, {
|
|
eventTypeId: rawBookingData.eventTypeId,
|
|
userId: userId,
|
|
eventTypeSlug: rawBookingData.eventTypeSlug,
|
|
});
|
|
|
|
const isPlatformBooking = !!platformClientId;
|
|
|
|
const eventType = await getEventType({
|
|
eventTypeId: rawBookingData.eventTypeId,
|
|
eventTypeSlug: rawBookingData.eventTypeSlug,
|
|
});
|
|
|
|
// Early validation: Check reschedule restrictions if rescheduling
|
|
await validateRescheduleRestrictions({
|
|
rescheduleUid: rawBookingData.rescheduleUid,
|
|
userId: userId ?? null,
|
|
eventType: eventType
|
|
? {
|
|
seatsPerTimeSlot: eventType.seatsPerTimeSlot,
|
|
minimumRescheduleNotice: eventType.minimumRescheduleNotice ?? null,
|
|
}
|
|
: null,
|
|
});
|
|
|
|
const bookingDataSchema = bookingDataSchemaGetter({
|
|
view: rawBookingData.rescheduleUid ? "reschedule" : "booking",
|
|
bookingFields: eventType.bookingFields,
|
|
});
|
|
|
|
const bookingData = await getBookingData({
|
|
reqBody: rawBookingData,
|
|
eventType,
|
|
schema: bookingDataSchema,
|
|
});
|
|
|
|
const {
|
|
recurringCount,
|
|
noEmail,
|
|
eventTypeId,
|
|
eventTypeSlug,
|
|
hasHashedBookingLink,
|
|
language,
|
|
appsStatus: reqAppsStatus,
|
|
name: bookerName,
|
|
attendeePhoneNumber: bookerPhoneNumber,
|
|
email: bookerEmail,
|
|
guests: reqGuests,
|
|
location,
|
|
notes: additionalNotes,
|
|
smsReminderNumber,
|
|
rescheduleReason,
|
|
luckyUsers,
|
|
routedTeamMemberIds,
|
|
reroutingFormResponses,
|
|
routingFormResponseId,
|
|
rrHostSubsetIds,
|
|
_isDryRun: isDryRun = false,
|
|
...reqBody
|
|
} = bookingData;
|
|
|
|
let troubleshooterData = buildTroubleshooterData({
|
|
eventType,
|
|
});
|
|
|
|
const emailsAndSmsHandler = new BookingEmailSmsHandler({ logger: tracingLogger });
|
|
|
|
try {
|
|
await checkIfBookerEmailIsBlocked({
|
|
loggedInUserId: userId,
|
|
bookerEmail,
|
|
verificationCode: reqBody.verificationCode,
|
|
isReschedule: !!rawBookingData.rescheduleUid,
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof ErrorWithCode) {
|
|
throw new HttpError({ statusCode: 403, message: error.message });
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
const spamCheckService = getSpamCheckService();
|
|
const eventOrganizationId = await getEventOrganizationId({
|
|
eventType,
|
|
});
|
|
|
|
spamCheckService.startCheck({ email: bookerEmail, organizationId: eventOrganizationId });
|
|
|
|
if (!rawBookingData.rescheduleUid) {
|
|
await checkActiveBookingsLimitForBooker({
|
|
eventTypeId,
|
|
maxActiveBookingsPerBooker: eventType.maxActiveBookingsPerBooker,
|
|
bookerEmail,
|
|
offerToRescheduleLastBooking: eventType.maxActiveBookingPerBookerOfferReschedule,
|
|
});
|
|
}
|
|
|
|
if (eventType.requiresBookerEmailVerification && !rawBookingData.rescheduleUid) {
|
|
const verificationCode = reqBody.verificationCode;
|
|
if (!verificationCode) {
|
|
throw new HttpError({
|
|
statusCode: 400,
|
|
message: "email_verification_required",
|
|
});
|
|
}
|
|
|
|
try {
|
|
await verifyCodeUnAuthenticated(bookerEmail, verificationCode);
|
|
} catch {
|
|
throw new HttpError({
|
|
statusCode: 400,
|
|
message: "invalid_verification_code",
|
|
});
|
|
}
|
|
}
|
|
|
|
if (isEventTypeLoggingEnabled({ eventTypeId, usernameOrTeamName: reqBody.user })) {
|
|
tracingLogger.settings.minLevel = 0;
|
|
}
|
|
|
|
const fullName = getFullName(bookerName);
|
|
// Why are we only using "en" locale
|
|
const tGuests = await getTranslation("en", "common");
|
|
|
|
const dynamicUserList = Array.isArray(reqBody.user) ? reqBody.user : getUsernameList(reqBody.user);
|
|
if (!eventType)
|
|
throw new HttpError({
|
|
statusCode: 404,
|
|
message: "event_type_not_found",
|
|
});
|
|
|
|
if (eventType.seatsPerTimeSlot && eventType.recurringEvent) {
|
|
throw new HttpError({
|
|
statusCode: 400,
|
|
message: "recurring_event_seats_error",
|
|
});
|
|
}
|
|
|
|
const bookingSeat = reqBody.rescheduleUid ? await getSeatedBooking(reqBody.rescheduleUid) : null;
|
|
const rescheduleUid = bookingSeat ? bookingSeat.booking.uid : reqBody.rescheduleUid;
|
|
const isNormalBookingOrFirstRecurringSlot = input.bookingData.allRecurringDates
|
|
? !!input.bookingData.isFirstRecurringSlot
|
|
: true;
|
|
|
|
let originalRescheduledBooking = rescheduleUid
|
|
? await getOriginalRescheduledBooking(rescheduleUid, !!eventType.seatsPerTimeSlot)
|
|
: null;
|
|
|
|
const paymentAppData = getPaymentAppData({
|
|
...eventType,
|
|
metadata: eventTypeMetaDataSchemaWithTypedApps.parse(eventType.metadata),
|
|
});
|
|
|
|
const { userReschedulingIsOwner, isConfirmedByDefault } = await getRequiresConfirmationFlags({
|
|
eventType,
|
|
bookingStartTime: reqBody.start,
|
|
userId,
|
|
originalRescheduledBookingOrganizerId: originalRescheduledBooking?.user?.id,
|
|
paymentAppData,
|
|
bookerEmail,
|
|
});
|
|
|
|
// For unconfirmed bookings or round robin bookings with the same attendee and timeslot, return the original booking
|
|
if (
|
|
(!isConfirmedByDefault && !userReschedulingIsOwner) ||
|
|
eventType.schedulingType === SchedulingType.ROUND_ROBIN
|
|
) {
|
|
const requiresPayment = !Number.isNaN(paymentAppData.price) && paymentAppData.price > 0;
|
|
|
|
const existingBooking = await deps.bookingRepository.getValidBookingFromEventTypeForAttendee({
|
|
eventTypeId,
|
|
bookerEmail,
|
|
bookerPhoneNumber,
|
|
startTime: new Date(dayjs(reqBody.start).utc().format()),
|
|
filterForUnconfirmed: !isConfirmedByDefault,
|
|
});
|
|
|
|
if (existingBooking) {
|
|
const hasPayments = existingBooking.payment.length > 0;
|
|
const isPaidBooking = existingBooking.paid || !hasPayments;
|
|
|
|
const shouldShowPaymentForm = requiresPayment && !isPaidBooking;
|
|
|
|
const firstPayment = shouldShowPaymentForm ? existingBooking.payment[0] : undefined;
|
|
|
|
const bookingResponse = {
|
|
...existingBooking,
|
|
user: {
|
|
...existingBooking.user,
|
|
email: null,
|
|
},
|
|
paymentRequired: shouldShowPaymentForm,
|
|
seatReferenceUid: "",
|
|
};
|
|
|
|
return {
|
|
...bookingResponse,
|
|
luckyUsers: bookingResponse.userId ? [bookingResponse.userId] : [],
|
|
isDryRun,
|
|
...(isDryRun ? { troubleshooterData } : {}),
|
|
paymentUid: firstPayment?.uid,
|
|
paymentId: firstPayment?.id,
|
|
organizationId: eventOrganizationId,
|
|
previousBooking: originalRescheduledBooking
|
|
? {
|
|
uid: originalRescheduledBooking.uid,
|
|
startTime: originalRescheduledBooking.startTime,
|
|
endTime: originalRescheduledBooking.endTime,
|
|
}
|
|
: null,
|
|
};
|
|
}
|
|
}
|
|
|
|
const isTeamEventType =
|
|
!!eventType.schedulingType && ["COLLECTIVE", "ROUND_ROBIN"].includes(eventType.schedulingType);
|
|
|
|
// Use "booking" mode to bypass cache for booking confirmation
|
|
const calendarFetchMode = "booking" as const;
|
|
|
|
tracingLogger.info(
|
|
`Booking eventType ${eventTypeId} started`,
|
|
safeStringify({
|
|
reqBody: {
|
|
user: reqBody.user,
|
|
eventTypeId,
|
|
eventTypeSlug,
|
|
startTime: reqBody.start,
|
|
endTime: reqBody.end,
|
|
rescheduleUid: reqBody.rescheduleUid,
|
|
location: location,
|
|
timeZone: reqBody.timeZone,
|
|
},
|
|
isTeamEventType,
|
|
eventType: getPiiFreeEventType(eventType),
|
|
dynamicUserList,
|
|
paymentAppData: {
|
|
enabled: paymentAppData.enabled,
|
|
price: paymentAppData.price,
|
|
paymentOption: paymentAppData.paymentOption,
|
|
currency: paymentAppData.currency,
|
|
appId: paymentAppData.appId,
|
|
},
|
|
})
|
|
);
|
|
|
|
const user = eventType.users.find((user) => user.id === eventType.userId);
|
|
const userSchedule = user?.schedules.find((schedule) => schedule.id === user?.defaultScheduleId);
|
|
const eventTimeZone = eventType.schedule?.timeZone ?? userSchedule?.timeZone;
|
|
|
|
await validateBookingTimeIsNotOutOfBounds<typeof eventType>(
|
|
reqBody.start,
|
|
reqBody.timeZone,
|
|
eventType,
|
|
eventTimeZone,
|
|
tracingLogger
|
|
);
|
|
|
|
validateEventLength({
|
|
reqBodyStart: reqBody.start,
|
|
reqBodyEnd: reqBody.end,
|
|
eventTypeMultipleDuration: eventType.metadata?.multipleDuration,
|
|
eventTypeLength: eventType.length,
|
|
logger: tracingLogger,
|
|
});
|
|
|
|
const contactOwnerFromReq = reqBody.teamMemberEmail ?? null;
|
|
|
|
const skipContactOwner = shouldIgnoreContactOwner({
|
|
skipContactOwner: reqBody.skipContactOwner ?? null,
|
|
rescheduleUid: reqBody.rescheduleUid ?? null,
|
|
routedTeamMemberIds: routedTeamMemberIds ?? null,
|
|
});
|
|
|
|
const contactOwnerEmail = skipContactOwner ? null : contactOwnerFromReq;
|
|
const crmRecordId: string | undefined = reqBody.crmRecordId ?? undefined;
|
|
|
|
let routingFormResponse = null;
|
|
|
|
if (routedTeamMemberIds) {
|
|
//routingFormResponseId could be 0 for dry run. So, we just avoid undefined value
|
|
if (routingFormResponseId === undefined) {
|
|
throw new HttpError({
|
|
statusCode: 400,
|
|
message: "Missing routingFormResponseId",
|
|
});
|
|
}
|
|
routingFormResponse = await deps.prismaClient.app_RoutingForms_FormResponse.findUnique({
|
|
where: {
|
|
id: routingFormResponseId,
|
|
},
|
|
select: {
|
|
response: true,
|
|
form: {
|
|
select: {
|
|
routes: true,
|
|
fields: true,
|
|
},
|
|
},
|
|
chosenRouteId: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
const { qualifiedRRUsers, additionalFallbackRRUsers, fixedUsers } = await loadAndValidateUsers({
|
|
hostname,
|
|
forcedSlug,
|
|
isPlatform: isPlatformBooking,
|
|
eventType,
|
|
eventTypeId,
|
|
dynamicUserList,
|
|
logger: tracingLogger,
|
|
routedTeamMemberIds: routedTeamMemberIds ?? null,
|
|
contactOwnerEmail,
|
|
rescheduleUid: reqBody.rescheduleUid || null,
|
|
routingFormResponse,
|
|
rrHostSubsetIds: rrHostSubsetIds ?? undefined,
|
|
});
|
|
|
|
// We filter out users but ensure allHostUsers remain same.
|
|
let users = [...qualifiedRRUsers, ...additionalFallbackRRUsers, ...fixedUsers];
|
|
|
|
const firstUser = users[0];
|
|
|
|
let { locationBodyString, organizerOrFirstDynamicGroupMemberDefaultLocationUrl } = getLocationValuesForDb({
|
|
dynamicUserList,
|
|
users,
|
|
location,
|
|
});
|
|
|
|
if (!skipEventLimitsCheck) {
|
|
await deps.checkBookingAndDurationLimitsService.checkBookingAndDurationLimits({
|
|
eventType,
|
|
reqBodyStart: reqBody.start,
|
|
reqBodyRescheduleUid: reqBody.rescheduleUid,
|
|
});
|
|
}
|
|
|
|
let luckyUserResponse;
|
|
let isFirstSeat = true;
|
|
let availableUsers: IsFixedAwareUser[] = [];
|
|
|
|
if (eventType.seatsPerTimeSlot) {
|
|
const booking = await deps.prismaClient.booking.findFirst({
|
|
where: {
|
|
eventTypeId: eventType.id,
|
|
startTime: new Date(dayjs(reqBody.start).utc().format()),
|
|
status: BookingStatus.ACCEPTED,
|
|
},
|
|
select: {
|
|
userId: true,
|
|
attendees: { select: { email: true } },
|
|
},
|
|
});
|
|
|
|
if (booking) {
|
|
isFirstSeat = false;
|
|
if (eventType.schedulingType === SchedulingType.ROUND_ROBIN) {
|
|
const fixedHosts = users.filter((user) => user.isFixed);
|
|
const originalNonFixedHost = users.find((user) => !user.isFixed && user.id === booking.userId);
|
|
|
|
if (originalNonFixedHost) {
|
|
users = [...fixedHosts, originalNonFixedHost];
|
|
} else {
|
|
const attendeeEmailSet = new Set(booking.attendees.map((attendee) => attendee.email));
|
|
|
|
// In this case, the first booking user is a fixed host, so the chosen non-fixed host is added as an attendee of the booking
|
|
const nonFixedAttendeeHost = users.find(
|
|
(user) => !user.isFixed && attendeeEmailSet.has(user.email)
|
|
);
|
|
users = [...fixedHosts, ...(nonFixedAttendeeHost ? [nonFixedAttendeeHost] : [])];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
//checks what users are available
|
|
if (isFirstSeat) {
|
|
const eventTypeWithUsers: Omit<getEventTypeResponse, "users"> & {
|
|
users: IsFixedAwareUserWithCredentials[];
|
|
} = {
|
|
...eventType,
|
|
minimumRescheduleNotice: eventType.minimumRescheduleNotice ?? null,
|
|
users: users as IsFixedAwareUserWithCredentials[],
|
|
...(eventType.recurringEvent && {
|
|
recurringEvent: {
|
|
...eventType.recurringEvent,
|
|
count: recurringCount || eventType.recurringEvent.count,
|
|
},
|
|
}),
|
|
};
|
|
if (
|
|
input.bookingData.allRecurringDates &&
|
|
input.bookingData.isFirstRecurringSlot &&
|
|
input.bookingData.numSlotsToCheckForAvailability
|
|
) {
|
|
const isTeamEvent =
|
|
eventType.schedulingType === SchedulingType.COLLECTIVE ||
|
|
eventType.schedulingType === SchedulingType.ROUND_ROBIN;
|
|
|
|
const fixedUsers = isTeamEvent
|
|
? eventTypeWithUsers.users.filter((user: IsFixedAwareUserWithCredentials) => user.isFixed)
|
|
: [];
|
|
|
|
for (
|
|
let i = 0;
|
|
i < input.bookingData.allRecurringDates.length &&
|
|
i < input.bookingData.numSlotsToCheckForAvailability;
|
|
i++
|
|
) {
|
|
const start = input.bookingData.allRecurringDates[i].start;
|
|
const end = input.bookingData.allRecurringDates[i].end;
|
|
if (isTeamEvent) {
|
|
// each fixed user must be available
|
|
for (const key in fixedUsers) {
|
|
if (!skipAvailabilityCheck) {
|
|
await ensureAvailableUsers(
|
|
{ ...eventTypeWithUsers, users: [fixedUsers[key]] },
|
|
{
|
|
dateFrom: dayjs(start).tz(reqBody.timeZone).format(),
|
|
dateTo: dayjs(end).tz(reqBody.timeZone).format(),
|
|
timeZone: reqBody.timeZone,
|
|
originalRescheduledBooking: originalRescheduledBooking ?? null,
|
|
},
|
|
tracingLogger,
|
|
calendarFetchMode
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
if (!skipAvailabilityCheck) {
|
|
await ensureAvailableUsers(
|
|
eventTypeWithUsers,
|
|
{
|
|
dateFrom: dayjs(start).tz(reqBody.timeZone).format(),
|
|
dateTo: dayjs(end).tz(reqBody.timeZone).format(),
|
|
timeZone: reqBody.timeZone,
|
|
originalRescheduledBooking,
|
|
},
|
|
tracingLogger,
|
|
calendarFetchMode
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!input.bookingData.allRecurringDates || input.bookingData.isFirstRecurringSlot) {
|
|
try {
|
|
if (!skipAvailabilityCheck) {
|
|
availableUsers = await ensureAvailableUsers(
|
|
{ ...eventTypeWithUsers, users: [...qualifiedRRUsers, ...fixedUsers] as IsFixedAwareUser[] },
|
|
{
|
|
dateFrom: dayjs(reqBody.start).tz(reqBody.timeZone).format(),
|
|
dateTo: dayjs(reqBody.end).tz(reqBody.timeZone).format(),
|
|
timeZone: reqBody.timeZone,
|
|
originalRescheduledBooking,
|
|
},
|
|
tracingLogger,
|
|
calendarFetchMode
|
|
);
|
|
} else {
|
|
availableUsers = [...qualifiedRRUsers, ...fixedUsers] as IsFixedAwareUser[];
|
|
}
|
|
} catch {
|
|
if (additionalFallbackRRUsers.length) {
|
|
tracingLogger.debug(
|
|
"Qualified users not available, check for fallback users",
|
|
safeStringify({
|
|
qualifiedRRUsers: qualifiedRRUsers.map((user) => user.id),
|
|
additionalFallbackRRUsers: additionalFallbackRRUsers.map((user) => user.id),
|
|
})
|
|
);
|
|
// can happen when contact owner not available for 2 weeks or fairness would block at least 2 weeks
|
|
// use fallback instead
|
|
if (!skipAvailabilityCheck) {
|
|
availableUsers = await ensureAvailableUsers(
|
|
{
|
|
...eventTypeWithUsers,
|
|
users: [...additionalFallbackRRUsers, ...fixedUsers] as IsFixedAwareUser[],
|
|
},
|
|
{
|
|
dateFrom: dayjs(reqBody.start).tz(reqBody.timeZone).format(),
|
|
dateTo: dayjs(reqBody.end).tz(reqBody.timeZone).format(),
|
|
timeZone: reqBody.timeZone,
|
|
originalRescheduledBooking,
|
|
},
|
|
tracingLogger,
|
|
calendarFetchMode
|
|
);
|
|
} else {
|
|
availableUsers = [...additionalFallbackRRUsers, ...fixedUsers] as IsFixedAwareUser[];
|
|
}
|
|
} else {
|
|
tracingLogger.debug(
|
|
"Qualified users not available, no fallback users",
|
|
safeStringify({
|
|
qualifiedRRUsers: qualifiedRRUsers.map((user) => user.id),
|
|
})
|
|
);
|
|
throw new Error(ErrorCode.NoAvailableUsersFound);
|
|
}
|
|
}
|
|
|
|
const fixedUserPool: IsFixedAwareUser[] = [];
|
|
const nonFixedUsers: IsFixedAwareUser[] = [];
|
|
|
|
availableUsers.forEach((user) => {
|
|
if (user.isFixed) {
|
|
fixedUserPool.push(user);
|
|
} else {
|
|
nonFixedUsers.push(user);
|
|
}
|
|
});
|
|
|
|
// Group non-fixed users by their group IDs
|
|
const luckyUserPools = groupHostsByGroupId({
|
|
hosts: nonFixedUsers,
|
|
hostGroups: eventType.hostGroups,
|
|
});
|
|
|
|
const notAvailableLuckyUsers: typeof users = [];
|
|
|
|
tracingLogger.debug(
|
|
"Computed available users",
|
|
safeStringify({
|
|
availableUsers: availableUsers.map((user) => user.id),
|
|
luckyUserPools: Object.fromEntries(
|
|
Object.entries(luckyUserPools).map(([groupId, users]) => [groupId, users.map((user) => user.id)])
|
|
),
|
|
})
|
|
);
|
|
|
|
const luckyUsers: typeof users = [];
|
|
// loop through all non-fixed hosts and get the lucky users
|
|
// This logic doesn't run when contactOwner is used because in that case, luckUsers.length === 1
|
|
for (const [groupId, luckyUserPool] of Object.entries(luckyUserPools)) {
|
|
let luckUserFound = false;
|
|
while (luckyUserPool.length > 0 && !luckUserFound) {
|
|
const freeUsers = luckyUserPool.filter(
|
|
(user) => !luckyUsers.concat(notAvailableLuckyUsers).find((existing) => existing.id === user.id)
|
|
);
|
|
// no more freeUsers after subtracting notAvailableLuckyUsers from luckyUsers :(
|
|
if (freeUsers.length === 0) break;
|
|
assertNonEmptyArray(freeUsers); // make sure TypeScript knows it too with an assertion; the error will never be thrown.
|
|
// freeUsers is ensured
|
|
|
|
const userIdsSet = new Set(users.map((user) => user.id));
|
|
const firstUserOrgId = await getOrgIdFromMemberOrTeamId({
|
|
memberId: eventTypeWithUsers.users[0].id ?? null,
|
|
teamId: eventType.teamId,
|
|
});
|
|
const newLuckyUser = await deps.luckyUserService.getLuckyUser({
|
|
// find a lucky user that is not already in the luckyUsers array
|
|
availableUsers: freeUsers,
|
|
// only hosts from the same group
|
|
allRRHosts: (
|
|
await enrichHostsWithDelegationCredentials({
|
|
orgId: firstUserOrgId ?? null,
|
|
hosts: eventTypeWithUsers.hosts,
|
|
})
|
|
).filter(
|
|
(host) =>
|
|
!host.isFixed &&
|
|
userIdsSet.has(host.user.id) &&
|
|
(host.groupId === groupId || (!host.groupId && groupId === DEFAULT_GROUP_ID))
|
|
),
|
|
eventType,
|
|
routingFormResponse,
|
|
meetingStartTime: new Date(reqBody.start),
|
|
});
|
|
if (!newLuckyUser) {
|
|
break; // prevent infinite loop
|
|
}
|
|
if (
|
|
input.bookingData.isFirstRecurringSlot &&
|
|
eventType.schedulingType === SchedulingType.ROUND_ROBIN &&
|
|
input.bookingData.numSlotsToCheckForAvailability &&
|
|
input.bookingData.allRecurringDates
|
|
) {
|
|
// for recurring round robin events check if lucky user is available for next slots
|
|
try {
|
|
for (
|
|
let i = 0;
|
|
i < input.bookingData.allRecurringDates.length &&
|
|
i < input.bookingData.numSlotsToCheckForAvailability;
|
|
i++
|
|
) {
|
|
const start = input.bookingData.allRecurringDates[i].start;
|
|
const end = input.bookingData.allRecurringDates[i].end;
|
|
|
|
if (!skipAvailabilityCheck) {
|
|
await ensureAvailableUsers(
|
|
{ ...eventTypeWithUsers, users: [newLuckyUser] },
|
|
{
|
|
dateFrom: dayjs(start).tz(reqBody.timeZone).format(),
|
|
dateTo: dayjs(end).tz(reqBody.timeZone).format(),
|
|
timeZone: reqBody.timeZone,
|
|
originalRescheduledBooking,
|
|
},
|
|
tracingLogger,
|
|
calendarFetchMode
|
|
);
|
|
}
|
|
}
|
|
// if no error, then lucky user is available for the next slots
|
|
luckyUsers.push(newLuckyUser);
|
|
luckUserFound = true;
|
|
} catch {
|
|
notAvailableLuckyUsers.push(newLuckyUser);
|
|
tracingLogger.info(
|
|
`Round robin host ${newLuckyUser.name} not available for first two slots. Trying to find another host.`
|
|
);
|
|
}
|
|
} else {
|
|
luckyUsers.push(newLuckyUser);
|
|
luckUserFound = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ALL fixed users must be available
|
|
if (fixedUserPool.length !== users.filter((user) => user.isFixed).length) {
|
|
throw new Error(ErrorCode.FixedHostsUnavailableForBooking);
|
|
}
|
|
|
|
const roundRobinHosts = eventType.hosts.filter((host) => !host.isFixed);
|
|
|
|
const hostGroups = groupHostsByGroupId({
|
|
hosts: roundRobinHosts,
|
|
hostGroups: eventType.hostGroups,
|
|
});
|
|
|
|
// Filter out host groups that have no hosts in them
|
|
const nonEmptyHostGroups = Object.fromEntries(
|
|
Object.entries(hostGroups).filter(([, hosts]) => hosts.length > 0)
|
|
);
|
|
// If there are RR hosts, we need to find a lucky user
|
|
if (
|
|
[...qualifiedRRUsers, ...additionalFallbackRRUsers].length > 0 &&
|
|
luckyUsers.length !== (Object.keys(nonEmptyHostGroups).length || 1)
|
|
) {
|
|
throw new Error(ErrorCode.RoundRobinHostsUnavailableForBooking);
|
|
}
|
|
|
|
// Pushing fixed user before the luckyUser guarantees the (first) fixed user as the organizer.
|
|
users = [...fixedUserPool, ...luckyUsers];
|
|
luckyUserResponse = { luckyUsers: luckyUsers.map((u) => u.id) };
|
|
troubleshooterData = {
|
|
...troubleshooterData,
|
|
luckyUsers: luckyUsers.map((u) => u.id),
|
|
fixedUsers: fixedUserPool.map((u) => u.id),
|
|
luckyUserPool: Object.values(luckyUserPools)
|
|
.flat()
|
|
.map((u) => u.id),
|
|
};
|
|
} else if (
|
|
input.bookingData.allRecurringDates &&
|
|
eventType.schedulingType === SchedulingType.ROUND_ROBIN
|
|
) {
|
|
// all recurring slots except the first one
|
|
const luckyUsersFromFirstBooking = luckyUsers
|
|
? eventTypeWithUsers.users.filter((user) => luckyUsers.find((luckyUserId) => luckyUserId === user.id))
|
|
: [];
|
|
const fixedHosts = eventTypeWithUsers.users.filter((user: IsFixedAwareUser) => user.isFixed);
|
|
users = [...fixedHosts, ...luckyUsersFromFirstBooking];
|
|
troubleshooterData = {
|
|
...troubleshooterData,
|
|
luckyUsersFromFirstBooking: luckyUsersFromFirstBooking.map((u) => u.id),
|
|
fixedUsers: fixedHosts.map((u) => u.id),
|
|
};
|
|
}
|
|
}
|
|
|
|
if (users.length === 0 && eventType.schedulingType === SchedulingType.ROUND_ROBIN) {
|
|
tracingLogger.error(`No available users found for round robin event.`);
|
|
throw new Error(ErrorCode.RoundRobinHostsUnavailableForBooking);
|
|
}
|
|
|
|
// If the team member is requested then they should be the organizer
|
|
const organizerUser = reqBody.teamMemberEmail
|
|
? (users.find((user) => user.email === reqBody.teamMemberEmail) ?? users[0])
|
|
: users[0];
|
|
|
|
const tOrganizer = await getTranslation(organizerUser?.locale ?? "en", "common");
|
|
const allCredentials = await getAllCredentialsIncludeServiceAccountKey(organizerUser, eventType);
|
|
|
|
// If the Organizer himself is rescheduling, the booker should be sent the communication in his timezone and locale.
|
|
const attendeeInfoOnReschedule =
|
|
userReschedulingIsOwner && originalRescheduledBooking
|
|
? originalRescheduledBooking.attendees.find((attendee) => attendee.email === bookerEmail)
|
|
: null;
|
|
|
|
const attendeeLanguage = attendeeInfoOnReschedule ? attendeeInfoOnReschedule.locale : language;
|
|
const attendeeTimezone = attendeeInfoOnReschedule ? attendeeInfoOnReschedule.timeZone : reqBody.timeZone;
|
|
|
|
const tAttendees = await getTranslation(attendeeLanguage ?? "en", "common");
|
|
|
|
const isManagedEventType = !!eventType.parentId;
|
|
|
|
// Track credential ID for per-host locations
|
|
let perHostCredentialId: number | undefined;
|
|
|
|
// Handle per-host custom locations for round-robin events
|
|
if (
|
|
eventType.enablePerHostLocations &&
|
|
eventType.schedulingType === SchedulingType.ROUND_ROBIN &&
|
|
organizerUser
|
|
) {
|
|
const organizerHost = eventType.hosts.find((host) => host.user.id === organizerUser.id);
|
|
if (organizerHost?.location) {
|
|
const result = await BookingLocationService.getPerHostLocation({
|
|
hostLocation: organizerHost.location,
|
|
allCredentials,
|
|
eventTypeId: eventType.id,
|
|
userId: organizerUser.id,
|
|
prismaClient: deps.prismaClient,
|
|
});
|
|
|
|
locationBodyString = result.locationBodyString;
|
|
organizerOrFirstDynamicGroupMemberDefaultLocationUrl = result.organizerDefaultLocationUrl;
|
|
perHostCredentialId = result.perHostCredentialId;
|
|
|
|
tracingLogger.info("Using per-host location", {
|
|
userId: organizerUser.id,
|
|
locationType: result.locationBodyString,
|
|
credentialId: result.perHostCredentialId,
|
|
});
|
|
}
|
|
}
|
|
|
|
// If location passed is empty , use default location of event
|
|
// If location of event is not set , use host default
|
|
if (locationBodyString.trim().length === 0) {
|
|
if (eventType.locations.length > 0) {
|
|
locationBodyString = eventType.locations[0].type;
|
|
} else {
|
|
locationBodyString = OrganizerDefaultConferencingAppType;
|
|
}
|
|
}
|
|
|
|
const organizationDefaultLocation = getFirstDelegationConferencingCredentialAppLocation({
|
|
credentials: firstUser.credentials,
|
|
});
|
|
|
|
// use host default
|
|
if (locationBodyString === OrganizerDefaultConferencingAppType) {
|
|
const metadataParseResult = userMetadataSchema.safeParse(organizerUser.metadata);
|
|
const organizerMetadata = metadataParseResult.success ? metadataParseResult.data : undefined;
|
|
const defaultApp = organizerMetadata?.defaultConferencingApp;
|
|
|
|
if (defaultApp?.appSlug) {
|
|
const app = getAppFromSlug(defaultApp.appSlug);
|
|
locationBodyString = app?.appData?.location?.type || locationBodyString;
|
|
|
|
const mainHostCalendar = eventType.destinationCalendar || organizerUser.destinationCalendar;
|
|
|
|
if (locationBodyString === MeetLocationType && mainHostCalendar?.integration !== "google_calendar") {
|
|
locationBodyString = "integrations:daily";
|
|
organizerOrFirstDynamicGroupMemberDefaultLocationUrl = undefined;
|
|
} else if (isManagedEventType || isTeamEventType) {
|
|
organizerOrFirstDynamicGroupMemberDefaultLocationUrl = defaultApp?.appLink;
|
|
}
|
|
} else {
|
|
locationBodyString = organizationDefaultLocation || "integrations:daily";
|
|
}
|
|
}
|
|
|
|
const invitee: Invitee = [
|
|
{
|
|
email: bookerEmail,
|
|
name: fullName,
|
|
phoneNumber: bookerPhoneNumber,
|
|
firstName: (typeof bookerName === "object" && bookerName.firstName) || "",
|
|
lastName: (typeof bookerName === "object" && bookerName.lastName) || "",
|
|
timeZone: attendeeTimezone,
|
|
language: { translate: tAttendees, locale: attendeeLanguage ?? "en" },
|
|
},
|
|
];
|
|
|
|
const blacklistedGuestEmails = process.env.BLACKLISTED_GUEST_EMAILS
|
|
? process.env.BLACKLISTED_GUEST_EMAILS.split(",")
|
|
: [];
|
|
|
|
const guestEmails = (reqGuests || []).map((email) => extractBaseEmail(email).toLowerCase());
|
|
const guestUsers = await deps.userRepository.findManyByEmailsWithEmailVerificationSettings({
|
|
emails: guestEmails,
|
|
});
|
|
|
|
const emailToRequiresVerification = new Map<string, boolean>();
|
|
for (const user of guestUsers) {
|
|
const matchedBase = extractBaseEmail(user.matchedEmail ?? user.email).toLowerCase();
|
|
emailToRequiresVerification.set(matchedBase, user.requiresBookerEmailVerification === true);
|
|
}
|
|
|
|
const guestsRemoved: string[] = [];
|
|
const guests = (reqGuests || []).reduce((guestArray, guest) => {
|
|
const baseGuestEmail = extractBaseEmail(guest).toLowerCase();
|
|
|
|
if (blacklistedGuestEmails.some((e) => e.toLowerCase() === baseGuestEmail)) {
|
|
guestsRemoved.push(guest);
|
|
return guestArray;
|
|
}
|
|
|
|
if (emailToRequiresVerification.get(baseGuestEmail)) {
|
|
guestsRemoved.push(guest);
|
|
return guestArray;
|
|
}
|
|
|
|
// If it's a team event, remove the team member from guests
|
|
if (isTeamEventType && users.some((user) => user.email === guest)) {
|
|
return guestArray;
|
|
}
|
|
guestArray.push({
|
|
email: guest,
|
|
name: "",
|
|
firstName: "",
|
|
lastName: "",
|
|
timeZone: attendeeTimezone,
|
|
language: { translate: tGuests, locale: "en" },
|
|
});
|
|
return guestArray;
|
|
}, [] as Invitee);
|
|
|
|
if (guestsRemoved.length > 0) {
|
|
tracingLogger.info("Removed guests from the booking", guestsRemoved);
|
|
}
|
|
|
|
const seed = `${organizerUser.username}:${dayjs(reqBody.start).utc().format()}:${new Date().getTime()}`;
|
|
const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL));
|
|
|
|
// For static link based video apps, it would have the static URL value instead of it's type(e.g. integrations:campfire_video)
|
|
// This ensures that createMeeting isn't called for static video apps as bookingLocation becomes just a regular value for them.
|
|
const { bookingLocation, conferenceCredentialId: eventTypeCredentialId } =
|
|
organizerOrFirstDynamicGroupMemberDefaultLocationUrl
|
|
? {
|
|
bookingLocation: organizerOrFirstDynamicGroupMemberDefaultLocationUrl,
|
|
conferenceCredentialId: undefined,
|
|
}
|
|
: getLocationValueForDB(locationBodyString, eventType.locations);
|
|
|
|
// Use per-host credential if available, otherwise fall back to event type credential
|
|
const conferenceCredentialId = perHostCredentialId ?? eventTypeCredentialId;
|
|
|
|
tracingLogger.info("locationBodyString", locationBodyString);
|
|
tracingLogger.info("event type locations", eventType.locations);
|
|
|
|
const customInputs = getCustomInputsResponses(reqBody, eventType.customInputs);
|
|
const attendeesList = [...invitee, ...guests];
|
|
|
|
const responses = reqBody.responses || null;
|
|
const evtName = !eventType?.isDynamic ? eventType.eventName : responses?.title;
|
|
const eventNameObject = {
|
|
//TODO: Can we have an unnamed attendee? If not, I would really like to throw an error here.
|
|
attendeeName: fullName || "Nameless",
|
|
eventType: eventType.title,
|
|
eventName: evtName,
|
|
// we send on behalf of team if >1 round robin attendee | collective
|
|
teamName: eventType.schedulingType === "COLLECTIVE" || users.length > 1 ? eventType.team?.name : null,
|
|
// TODO: Can we have an unnamed organizer? If not, I would really like to throw an error here.
|
|
host: organizerUser.name || "Nameless",
|
|
location: bookingLocation,
|
|
eventDuration: dayjs(reqBody.end).diff(reqBody.start, "minutes"),
|
|
bookingFields: { ...responses },
|
|
t: tOrganizer,
|
|
};
|
|
|
|
const iCalUID = getICalUID({
|
|
event: { iCalUID: originalRescheduledBooking?.iCalUID, uid: originalRescheduledBooking?.uid },
|
|
uid,
|
|
});
|
|
// For bookings made before introducing iCalSequence, assume that the sequence should start at 1. For new bookings start at 0.
|
|
const iCalSequence = getICalSequence(originalRescheduledBooking);
|
|
const organizerOrganizationProfile = await deps.prismaClient.profile.findFirst({
|
|
where: {
|
|
userId: organizerUser.id,
|
|
},
|
|
});
|
|
|
|
const organizerOrganizationId = organizerOrganizationProfile?.organizationId;
|
|
const bookerUrl = eventType.team
|
|
? await getBookerBaseUrl(eventType.team.parentId)
|
|
: await getBookerBaseUrl(organizerOrganizationId ?? null);
|
|
|
|
const destinationCalendar = eventType.destinationCalendar
|
|
? [eventType.destinationCalendar]
|
|
: organizerUser.destinationCalendar
|
|
? [organizerUser.destinationCalendar]
|
|
: null;
|
|
|
|
let organizerEmail = organizerUser.email || "Email-less";
|
|
if (eventType.useEventTypeDestinationCalendarEmail && destinationCalendar?.[0]?.primaryEmail) {
|
|
organizerEmail = destinationCalendar[0].primaryEmail;
|
|
} else if (eventType.secondaryEmailId && eventType.secondaryEmail?.email) {
|
|
organizerEmail = eventType.secondaryEmail.email;
|
|
}
|
|
|
|
//update cal event responses with latest location value , later used by webhook
|
|
if (reqBody.calEventResponses)
|
|
reqBody.calEventResponses["location"].value = {
|
|
value: platformBookingLocation ?? bookingLocation,
|
|
optionValue: "",
|
|
};
|
|
|
|
const eventName = getEventName(eventNameObject);
|
|
|
|
const builtEvt = new CalendarEventBuilder()
|
|
.withBasicDetails({
|
|
bookerUrl,
|
|
title: eventName,
|
|
startTime: dayjs(reqBody.start).utc().format(),
|
|
endTime: dayjs(reqBody.end).utc().format(),
|
|
additionalNotes,
|
|
})
|
|
.withEventType({
|
|
slug: eventType.slug,
|
|
description: eventType.description,
|
|
id: eventType.id,
|
|
hideCalendarNotes: eventType.hideCalendarNotes,
|
|
hideCalendarEventDetails: eventType.hideCalendarEventDetails,
|
|
hideOrganizerEmail: eventType.hideOrganizerEmail,
|
|
schedulingType: eventType.schedulingType,
|
|
seatsPerTimeSlot: eventType.seatsPerTimeSlot,
|
|
// if seats are not enabled we should default true
|
|
seatsShowAttendees: eventType.seatsPerTimeSlot ? eventType.seatsShowAttendees : true,
|
|
seatsShowAvailabilityCount: eventType.seatsPerTimeSlot ? eventType.seatsShowAvailabilityCount : true,
|
|
customReplyToEmail: eventType.customReplyToEmail,
|
|
disableRescheduling: eventType.disableRescheduling ?? false,
|
|
disableCancelling: eventType.disableCancelling ?? false,
|
|
})
|
|
.withOrganizer({
|
|
id: organizerUser.id,
|
|
name: organizerUser.name || "Nameless",
|
|
email: organizerEmail,
|
|
username: organizerUser.username || undefined,
|
|
usernameInOrg: organizerOrganizationProfile?.username || undefined,
|
|
timeZone: organizerUser.timeZone,
|
|
language: { translate: tOrganizer, locale: organizerUser.locale ?? "en" },
|
|
timeFormat: getTimeFormatStringFromUserTimeFormat(organizerUser.timeFormat),
|
|
})
|
|
.withAttendees(attendeesList)
|
|
.withMetadataAndResponses({
|
|
additionalNotes,
|
|
customInputs,
|
|
responses: reqBody.calEventResponses || null,
|
|
userFieldsResponses: reqBody.calEventUserFieldsResponses || null,
|
|
})
|
|
.withLocation({
|
|
location: platformBookingLocation ?? bookingLocation, // Will be processed by the EventManager later.
|
|
conferenceCredentialId,
|
|
})
|
|
.withDestinationCalendar(destinationCalendar)
|
|
.withIdentifiers({ iCalUID, iCalSequence })
|
|
.withConfirmation({
|
|
requiresConfirmation: !isConfirmedByDefault,
|
|
isConfirmedByDefault,
|
|
})
|
|
.withPlatformVariables({
|
|
platformClientId,
|
|
platformRescheduleUrl,
|
|
platformCancelUrl,
|
|
platformBookingUrl,
|
|
})
|
|
.withOrganization(organizerOrganizationId)
|
|
.withHashedLink(hasHashedBookingLink ? (reqBody.hashedLink ?? null) : null)
|
|
.build();
|
|
|
|
if (!builtEvt) {
|
|
throw new HttpError({
|
|
statusCode: 400,
|
|
message: "Failed to build calendar event due to missing required fields",
|
|
});
|
|
}
|
|
|
|
let evt: CalendarEvent = builtEvt;
|
|
|
|
if (input.bookingData.thirdPartyRecurringEventId) {
|
|
const updatedEvt = CalendarEventBuilder.fromEvent(evt)
|
|
?.withRecurringEventId(input.bookingData.thirdPartyRecurringEventId)
|
|
.build();
|
|
|
|
if (!updatedEvt) {
|
|
throw new HttpError({
|
|
statusCode: 400,
|
|
message: "Failed to build event with recurring event ID due to missing required fields",
|
|
});
|
|
}
|
|
|
|
evt = updatedEvt;
|
|
}
|
|
|
|
if (isTeamEventType) {
|
|
const teamEvt = await buildEventForTeamEventType({
|
|
existingEvent: evt,
|
|
schedulingType: eventType.schedulingType,
|
|
users,
|
|
team: eventType.team,
|
|
organizerUser,
|
|
});
|
|
|
|
if (!teamEvt) {
|
|
throw new HttpError({ statusCode: 400, message: "Failed to build team event" });
|
|
}
|
|
|
|
evt = teamEvt;
|
|
}
|
|
|
|
// data needed for triggering webhooks
|
|
const eventTypeInfo: EventTypeInfo = {
|
|
eventTitle: eventType.title,
|
|
eventDescription: eventType.description,
|
|
price: paymentAppData.price,
|
|
currency: eventType.currency,
|
|
length: dayjs(reqBody.end).diff(dayjs(reqBody.start), "minutes"),
|
|
};
|
|
|
|
const teamId = await getTeamIdFromEventType({ eventType });
|
|
|
|
const triggerForUser = !teamId || (teamId && eventType.parentId);
|
|
|
|
const organizerUserId = triggerForUser ? organizerUser.id : null;
|
|
|
|
const orgId = await getOrgIdFromMemberOrTeamId({ memberId: organizerUserId, teamId });
|
|
|
|
const subscriberOptions: GetSubscriberOptions = {
|
|
userId: organizerUserId,
|
|
eventTypeId,
|
|
triggerEvent: WebhookTriggerEvents.BOOKING_CREATED,
|
|
teamId,
|
|
orgId,
|
|
oAuthClientId: platformClientId,
|
|
};
|
|
|
|
const eventTrigger: WebhookTriggerEvents = rescheduleUid
|
|
? WebhookTriggerEvents.BOOKING_RESCHEDULED
|
|
: WebhookTriggerEvents.BOOKING_CREATED;
|
|
|
|
subscriberOptions.triggerEvent = eventTrigger;
|
|
|
|
const subscriberOptionsMeetingEnded = {
|
|
userId: triggerForUser ? organizerUser.id : null,
|
|
eventTypeId,
|
|
triggerEvent: WebhookTriggerEvents.MEETING_ENDED,
|
|
teamId,
|
|
orgId,
|
|
oAuthClientId: platformClientId,
|
|
};
|
|
|
|
const subscriberOptionsMeetingStarted = {
|
|
userId: triggerForUser ? organizerUser.id : null,
|
|
eventTypeId,
|
|
triggerEvent: WebhookTriggerEvents.MEETING_STARTED,
|
|
teamId,
|
|
orgId,
|
|
oAuthClientId: platformClientId,
|
|
};
|
|
|
|
const workflows = await getAllWorkflowsFromEventType(
|
|
{
|
|
...eventType,
|
|
metadata: eventTypeMetaDataSchemaWithTypedApps.parse(eventType.metadata),
|
|
},
|
|
organizerUser.id
|
|
);
|
|
|
|
const spamCheckResult = await spamCheckService.waitForCheck();
|
|
|
|
if (spamCheckResult.isBlocked) {
|
|
const DECOY_ORGANIZER_NAMES = ["Alex Smith", "Jordan Taylor", "Sam Johnson", "Chris Morgan"];
|
|
const randomOrganizerName =
|
|
DECOY_ORGANIZER_NAMES[Math.floor(Math.random() * DECOY_ORGANIZER_NAMES.length)];
|
|
|
|
const eventName = getEventName({
|
|
...eventNameObject,
|
|
host: randomOrganizerName,
|
|
});
|
|
|
|
return {
|
|
id: 0,
|
|
uid,
|
|
iCalUID: "",
|
|
status: BookingStatus.ACCEPTED,
|
|
eventTypeId: eventType.id,
|
|
user: {
|
|
name: randomOrganizerName,
|
|
timeZone: "UTC",
|
|
email: null,
|
|
},
|
|
userId: null,
|
|
userUuid: null,
|
|
title: eventName,
|
|
startTime: new Date(reqBody.start),
|
|
endTime: new Date(reqBody.end),
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
attendees: [
|
|
{
|
|
id: 0,
|
|
email: bookerEmail,
|
|
name: fullName,
|
|
timeZone: reqBody.timeZone,
|
|
locale: null,
|
|
phoneNumber: null,
|
|
bookingId: null,
|
|
noShow: null,
|
|
},
|
|
],
|
|
oneTimePassword: null,
|
|
smsReminderNumber: null,
|
|
metadata: {},
|
|
idempotencyKey: null,
|
|
userPrimaryEmail: null,
|
|
description: eventType.description || null,
|
|
customInputs: null,
|
|
responses: null,
|
|
location: bookingLocation,
|
|
paid: false,
|
|
cancellationReason: null,
|
|
rejectionReason: null,
|
|
dynamicEventSlugRef: null,
|
|
dynamicGroupSlugRef: null,
|
|
fromReschedule: null,
|
|
recurringEventId: null,
|
|
scheduledJobs: [],
|
|
rescheduledBy: null,
|
|
destinationCalendarId: null,
|
|
reassignReason: null,
|
|
reassignById: null,
|
|
rescheduled: false,
|
|
isRecorded: false,
|
|
iCalSequence: 0,
|
|
rating: null,
|
|
ratingFeedback: null,
|
|
noShowHost: null,
|
|
cancelledBy: null,
|
|
creationSource: CreationSource.WEBAPP,
|
|
references: [],
|
|
payment: [],
|
|
isDryRun: false,
|
|
paymentRequired: false,
|
|
paymentUid: undefined,
|
|
luckyUsers: [],
|
|
paymentId: undefined,
|
|
seatReferenceUid: undefined,
|
|
isShortCircuitedBooking: true,
|
|
organizationId: eventOrganizationId,
|
|
previousBooking: originalRescheduledBooking
|
|
? {
|
|
uid: originalRescheduledBooking.uid,
|
|
startTime: originalRescheduledBooking.startTime,
|
|
endTime: originalRescheduledBooking.endTime,
|
|
}
|
|
: null,
|
|
};
|
|
}
|
|
|
|
const actionSource = getAuditActionSource({
|
|
creationSource: input.bookingData.creationSource,
|
|
eventTypeId,
|
|
rescheduleUid: originalRescheduledBooking?.uid ?? null,
|
|
});
|
|
|
|
// For seats, if the booking already exists then we want to add the new attendee to the existing booking
|
|
if (eventType.seatsPerTimeSlot) {
|
|
const newBooking = await handleSeats(
|
|
{
|
|
rescheduleUid,
|
|
reqBookingUid: reqBody.bookingUid,
|
|
eventType,
|
|
evt: { ...evt, seatsPerTimeSlot: eventType.seatsPerTimeSlot, bookerUrl },
|
|
invitee,
|
|
allCredentials,
|
|
organizerUser,
|
|
originalRescheduledBooking,
|
|
bookerEmail,
|
|
bookerPhoneNumber,
|
|
tAttendees,
|
|
bookingSeat,
|
|
reqUserId: input.userId,
|
|
reqUserUuid: userUuid,
|
|
rescheduleReason,
|
|
reqBodyUser: reqBody.user,
|
|
noEmail,
|
|
isConfirmedByDefault,
|
|
additionalNotes,
|
|
reqAppsStatus,
|
|
attendeeLanguage,
|
|
paymentAppData,
|
|
fullName,
|
|
smsReminderNumber,
|
|
eventTypeInfo,
|
|
uid,
|
|
eventTypeId,
|
|
reqBodyMetadata: reqBody.metadata,
|
|
subscriberOptions,
|
|
eventTrigger,
|
|
responses,
|
|
workflows,
|
|
rescheduledBy: reqBody.rescheduledBy,
|
|
isDryRun,
|
|
organizationId: eventOrganizationId,
|
|
actionSource,
|
|
traceContext,
|
|
deps,
|
|
},
|
|
deps.featuresRepository
|
|
);
|
|
|
|
if (newBooking) {
|
|
const bookingResponse = {
|
|
...newBooking,
|
|
user: {
|
|
...newBooking.user,
|
|
email: null,
|
|
},
|
|
paymentRequired: false,
|
|
isDryRun: isDryRun,
|
|
...(isDryRun ? { troubleshooterData } : {}),
|
|
};
|
|
return {
|
|
...bookingResponse,
|
|
...luckyUserResponse,
|
|
organizationId: eventOrganizationId,
|
|
previousBooking: originalRescheduledBooking
|
|
? {
|
|
uid: originalRescheduledBooking.uid,
|
|
startTime: originalRescheduledBooking.startTime,
|
|
endTime: originalRescheduledBooking.endTime,
|
|
}
|
|
: null,
|
|
};
|
|
} else {
|
|
// Rescheduling logic for the original seated event was handled in handleSeats
|
|
// We want to use new booking logic for the new time slot
|
|
originalRescheduledBooking = null;
|
|
const updatedEvt = CalendarEventBuilder.fromEvent(evt)
|
|
?.withIdentifiers({
|
|
iCalUID: getICalUID({
|
|
attendeeId: bookingSeat?.attendeeId,
|
|
}),
|
|
})
|
|
.build();
|
|
|
|
if (!updatedEvt) {
|
|
throw new HttpError({
|
|
statusCode: 400,
|
|
message: "Failed to build event with new identifiers due to missing required fields",
|
|
});
|
|
}
|
|
|
|
evt = updatedEvt;
|
|
}
|
|
}
|
|
|
|
if (reqBody.recurringEventId && eventType.recurringEvent) {
|
|
// Overriding the recurring event configuration count to be the actual number of events booked for
|
|
// the recurring event (equal or less than recurring event configuration count)
|
|
eventType.recurringEvent = Object.assign({}, eventType.recurringEvent, { count: recurringCount });
|
|
evt.recurringEvent = eventType.recurringEvent;
|
|
}
|
|
|
|
const changedOrganizer =
|
|
!!originalRescheduledBooking &&
|
|
(eventType.schedulingType === SchedulingType.ROUND_ROBIN ||
|
|
eventType.schedulingType === SchedulingType.COLLECTIVE) &&
|
|
originalRescheduledBooking.userId !== evt.organizer.id;
|
|
|
|
const skipDeleteEventsAndMeetings = changedOrganizer;
|
|
|
|
const isBookingRequestedReschedule =
|
|
!!originalRescheduledBooking &&
|
|
!!originalRescheduledBooking.rescheduled &&
|
|
originalRescheduledBooking.status === BookingStatus.CANCELLED;
|
|
|
|
if (
|
|
changedOrganizer &&
|
|
originalRescheduledBooking &&
|
|
originalRescheduledBooking?.user?.name &&
|
|
organizerUser?.name
|
|
) {
|
|
evt.title = updateHostInEventName(
|
|
originalRescheduledBooking.title,
|
|
originalRescheduledBooking.user.name,
|
|
organizerUser.name
|
|
);
|
|
}
|
|
|
|
let results: EventResult<AdditionalInformation & { url?: string; iCalUID?: string }>[] = [];
|
|
let referencesToCreate: PartialReference[] = [];
|
|
|
|
let booking: CreatedBooking | null = null;
|
|
|
|
tracingLogger.debug(
|
|
"Going to create booking in DB now",
|
|
safeStringify({
|
|
organizerUser: organizerUser.id,
|
|
attendeesList: attendeesList.map((guest) => ({ timeZone: guest.timeZone })),
|
|
requiresConfirmation: evt.requiresConfirmation,
|
|
isConfirmedByDefault,
|
|
userReschedulingIsOwner,
|
|
})
|
|
);
|
|
|
|
let assignmentReason: { reasonEnum: AssignmentReasonEnum; reasonString: string } | undefined;
|
|
|
|
try {
|
|
if (!isDryRun) {
|
|
booking = await createBooking({
|
|
uid,
|
|
rescheduledBy: reqBody.rescheduledBy,
|
|
routingFormResponseId: routingFormResponseId,
|
|
reroutingFormResponses: reroutingFormResponses ?? null,
|
|
reqBody: {
|
|
user: reqBody.user,
|
|
metadata: reqBody.metadata,
|
|
recurringEventId: reqBody.recurringEventId,
|
|
},
|
|
eventType: {
|
|
eventTypeData: eventType,
|
|
id: eventTypeId,
|
|
slug: eventTypeSlug,
|
|
organizerUser,
|
|
isConfirmedByDefault,
|
|
paymentAppData,
|
|
},
|
|
input: {
|
|
bookerEmail,
|
|
rescheduleReason,
|
|
smsReminderNumber,
|
|
responses,
|
|
},
|
|
evt,
|
|
originalRescheduledBooking,
|
|
creationSource: input.bookingData.creationSource,
|
|
tracking: reqBody.tracking,
|
|
});
|
|
|
|
if (booking?.userId) {
|
|
const usersRepository = new UsersRepository();
|
|
await usersRepository.updateLastActiveAt(booking.userId);
|
|
const organizerUserAvailability = availableUsers.find((user) => user.id === booking?.userId);
|
|
|
|
criticalLogger.info(`Booking created`, {
|
|
bookingUid: booking.uid,
|
|
selectedCalendarIds: organizerUser.allSelectedCalendars?.map((c) => c.id) ?? [],
|
|
availabilitySnapshot: organizerUserAvailability?.availabilityData
|
|
? formatAvailabilitySnapshot(organizerUserAvailability.availabilityData)
|
|
: null,
|
|
});
|
|
}
|
|
|
|
// If it's a round robin event, record the reason for the host assignment
|
|
if (eventType.schedulingType === SchedulingType.ROUND_ROBIN && routingFormResponseId) {
|
|
try {
|
|
const routingTraceService = getRoutingTraceService();
|
|
const result = await routingTraceService.processForBooking({
|
|
formResponseId: routingFormResponseId,
|
|
bookingId: booking.id,
|
|
bookingUid: booking.uid,
|
|
organizerEmail: organizerUser.email,
|
|
isRerouting: !!reroutingFormResponses,
|
|
reroutedByEmail: reqBody.rescheduledBy,
|
|
});
|
|
if (result?.assignmentReason) {
|
|
assignmentReason = result.assignmentReason;
|
|
}
|
|
} catch (error) {
|
|
criticalLogger.warn("Failed to process routing trace", { error });
|
|
}
|
|
}
|
|
|
|
const updatedEvtWithUid = CalendarEventBuilder.fromEvent(evt)
|
|
?.withUid(booking.uid ?? null)
|
|
.build();
|
|
|
|
if (!updatedEvtWithUid) {
|
|
throw new HttpError({
|
|
statusCode: 400,
|
|
message: "Failed to build event with UID due to missing required fields",
|
|
});
|
|
}
|
|
|
|
evt = updatedEvtWithUid;
|
|
|
|
const updatedEvtWithPassword = CalendarEventBuilder.fromEvent(evt)
|
|
?.withOneTimePassword(booking.oneTimePassword ?? null)
|
|
.build();
|
|
|
|
if (!updatedEvtWithPassword) {
|
|
throw new HttpError({
|
|
statusCode: 400,
|
|
message: "Failed to build event with one-time password due to missing required fields",
|
|
});
|
|
}
|
|
|
|
evt = updatedEvtWithPassword;
|
|
|
|
// Add assignment reason to evt for emails
|
|
if (assignmentReason) {
|
|
const updatedEvtWithAssignmentReason = CalendarEventBuilder.fromEvent(evt)
|
|
?.withAssignmentReason({
|
|
category: getAssignmentReasonCategory(assignmentReason.reasonEnum),
|
|
details: assignmentReason.reasonString ?? null,
|
|
})
|
|
.build();
|
|
|
|
if (updatedEvtWithAssignmentReason) {
|
|
evt = updatedEvtWithAssignmentReason;
|
|
}
|
|
}
|
|
|
|
if (booking && booking.id && eventType.seatsPerTimeSlot) {
|
|
const currentAttendee = booking.attendees.find(
|
|
(attendee) =>
|
|
attendee.email === bookingData.responses.email ||
|
|
(bookingData.responses.attendeePhoneNumber &&
|
|
attendee.phoneNumber === bookingData.responses.attendeePhoneNumber)
|
|
);
|
|
|
|
// Save description to bookingSeat
|
|
const uniqueAttendeeId = uuid();
|
|
await deps.prismaClient.bookingSeat.create({
|
|
data: {
|
|
referenceUid: uniqueAttendeeId,
|
|
data: {
|
|
description: additionalNotes,
|
|
responses,
|
|
},
|
|
metadata: reqBody.metadata,
|
|
booking: {
|
|
connect: {
|
|
id: booking.id,
|
|
},
|
|
},
|
|
attendee: {
|
|
connect: {
|
|
id: currentAttendee?.id,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
evt.attendeeSeatId = uniqueAttendeeId;
|
|
}
|
|
} else {
|
|
const { booking: dryRunBooking, troubleshooterData: _troubleshooterData } = buildDryRunBooking({
|
|
eventTypeId,
|
|
organizerUser,
|
|
eventName,
|
|
startTime: reqBody.start,
|
|
endTime: reqBody.end,
|
|
contactOwnerFromReq,
|
|
contactOwnerEmail,
|
|
allHostUsers: users,
|
|
isManagedEventType,
|
|
});
|
|
|
|
booking = dryRunBooking;
|
|
troubleshooterData = {
|
|
...troubleshooterData,
|
|
..._troubleshooterData,
|
|
};
|
|
}
|
|
} catch (_err) {
|
|
const err = getServerErrorFromUnknown(_err);
|
|
tracingLogger.error(`Booking ${eventTypeId} failed`, "Error when saving booking to db", err.message);
|
|
if (err.cause && typeof err.cause === "object" && "code" in err.cause && err.cause.code === "P2002") {
|
|
throw new HttpError({
|
|
statusCode: 409,
|
|
message: ErrorCode.BookingConflict,
|
|
});
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
// After polling videoBusyTimes, credentials might have been changed due to refreshment, so query them again.
|
|
const credentials = await refreshCredentials(allCredentials);
|
|
const apps = eventTypeAppMetadataOptionalSchema.parse(eventType?.metadata?.apps);
|
|
const eventManager =
|
|
!isDryRun && !skipCalendarSyncTaskCreation
|
|
? new EventManager({ ...organizerUser, credentials }, apps)
|
|
: buildDryRunEventManager();
|
|
|
|
let videoCallUrl;
|
|
|
|
// this is the actual rescheduling logic
|
|
if (!eventType.seatsPerTimeSlot && originalRescheduledBooking?.uid) {
|
|
tracingLogger.silly("Rescheduling booking", originalRescheduledBooking.uid);
|
|
// cancel workflow reminders from previous rescheduled booking
|
|
await WorkflowRepository.deleteAllWorkflowReminders(originalRescheduledBooking.workflowReminders);
|
|
|
|
evt = addVideoCallDataToEvent(originalRescheduledBooking.references, evt);
|
|
evt.rescheduledBy = reqBody.rescheduledBy;
|
|
|
|
// If organizer is changed in RR event then we need to delete the previous host destination calendar events
|
|
const previousHostDestinationCalendar = originalRescheduledBooking?.destinationCalendar
|
|
? [originalRescheduledBooking?.destinationCalendar]
|
|
: [];
|
|
|
|
if (changedOrganizer) {
|
|
// 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;
|
|
evt.hasOrganizerChanged = true;
|
|
}
|
|
|
|
if (changedOrganizer && originalRescheduledBooking?.user) {
|
|
const originalHostCredentials = await getAllCredentialsIncludeServiceAccountKey(
|
|
originalRescheduledBooking.user,
|
|
eventType
|
|
);
|
|
const refreshedOriginalHostCredentials = await refreshCredentials(originalHostCredentials);
|
|
|
|
// Create EventManager with original host's credentials for deletion operations
|
|
const originalHostEventManager = new EventManager(
|
|
{ ...originalRescheduledBooking.user, credentials: refreshedOriginalHostCredentials },
|
|
apps
|
|
);
|
|
tracingLogger.debug("RescheduleOrganizerChanged: Deleting Event and Meeting for previous booking");
|
|
// Create deletion event with original host's organizer info and original booking properties
|
|
const deletionEvent = {
|
|
...evt,
|
|
organizer: {
|
|
id: originalRescheduledBooking.user.id,
|
|
name: originalRescheduledBooking.user.name || "",
|
|
email: originalRescheduledBooking.user.email,
|
|
username: originalRescheduledBooking.user.username || undefined,
|
|
timeZone: originalRescheduledBooking.user.timeZone,
|
|
language: { translate: tOrganizer, locale: originalRescheduledBooking.user.locale ?? "en" },
|
|
timeFormat: getTimeFormatStringFromUserTimeFormat(originalRescheduledBooking.user.timeFormat),
|
|
},
|
|
destinationCalendar: previousHostDestinationCalendar,
|
|
// Override with original booking properties used by deletion operations
|
|
startTime: originalRescheduledBooking.startTime.toISOString(),
|
|
endTime: originalRescheduledBooking.endTime.toISOString(),
|
|
uid: originalRescheduledBooking.uid,
|
|
location: originalRescheduledBooking.location,
|
|
responses: originalRescheduledBooking.responses
|
|
? (originalRescheduledBooking.responses as CalEventResponses)
|
|
: evt.responses,
|
|
};
|
|
|
|
if (!skipCalendarSyncTaskCreation) {
|
|
await originalHostEventManager.deleteEventsAndMeetings({
|
|
event: deletionEvent,
|
|
bookingReferences: originalRescheduledBooking.references,
|
|
});
|
|
}
|
|
}
|
|
// This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back
|
|
// to the default description when we are sending the emails.
|
|
evt.description = eventType.description;
|
|
|
|
const updateManager = !skipCalendarSyncTaskCreation
|
|
? await eventManager.reschedule(
|
|
evt,
|
|
originalRescheduledBooking.uid,
|
|
undefined,
|
|
changedOrganizer,
|
|
previousHostDestinationCalendar,
|
|
isBookingRequestedReschedule,
|
|
skipDeleteEventsAndMeetings
|
|
)
|
|
: placeholderCreatedEvent;
|
|
|
|
results = updateManager.results;
|
|
referencesToCreate = updateManager.referencesToCreate;
|
|
|
|
videoCallUrl = evt.videoCallData && evt.videoCallData.url ? evt.videoCallData.url : null;
|
|
|
|
// This gets overridden when creating the event - to check if notes have been hidden or not. We just reset this back
|
|
// to the default description when we are sending the emails.
|
|
evt.description = eventType.description;
|
|
|
|
const { metadata: videoMetadata, videoCallUrl: _videoCallUrl } = getVideoCallDetails({
|
|
results,
|
|
});
|
|
|
|
let metadata: AdditionalInformation = {};
|
|
metadata = videoMetadata;
|
|
videoCallUrl = _videoCallUrl;
|
|
|
|
const isThereAnIntegrationError = results && results.some((res) => !res.success);
|
|
|
|
if (isThereAnIntegrationError) {
|
|
const error = {
|
|
errorCode: "BookingReschedulingMeetingFailed",
|
|
message: "Booking Rescheduling failed",
|
|
};
|
|
|
|
tracingLogger.error(
|
|
`EventManager.reschedule failure in some of the integrations ${organizerUser.username}`,
|
|
safeStringify({ error, results })
|
|
);
|
|
} else {
|
|
if (results.length) {
|
|
// Handle Google Meet results
|
|
// We use the original booking location since the evt location changes to daily
|
|
if (bookingLocation === MeetLocationType) {
|
|
const googleMeetResult = {
|
|
appName: GoogleMeetMetadata.name,
|
|
type: "conferencing",
|
|
uid: results[0].uid,
|
|
originalEvent: results[0].originalEvent,
|
|
};
|
|
|
|
// Find index of google_calendar inside createManager.referencesToCreate
|
|
const googleCalIndex = updateManager.referencesToCreate.findIndex(
|
|
(ref) => ref.type === "google_calendar"
|
|
);
|
|
const googleCalResult = results[googleCalIndex];
|
|
|
|
if (!googleCalResult) {
|
|
tracingLogger.warn("Google Calendar not installed but using Google Meet as location");
|
|
results.push({
|
|
...googleMeetResult,
|
|
success: false,
|
|
calWarnings: [tOrganizer("google_meet_warning")],
|
|
});
|
|
}
|
|
|
|
const googleHangoutLink = Array.isArray(googleCalResult?.updatedEvent)
|
|
? googleCalResult.updatedEvent[0]?.hangoutLink
|
|
: (googleCalResult?.updatedEvent?.hangoutLink ?? googleCalResult?.createdEvent?.hangoutLink);
|
|
|
|
if (googleHangoutLink) {
|
|
results.push({
|
|
...googleMeetResult,
|
|
success: true,
|
|
});
|
|
|
|
// Add google_meet to referencesToCreate in the same index as google_calendar
|
|
updateManager.referencesToCreate[googleCalIndex] = {
|
|
...updateManager.referencesToCreate[googleCalIndex],
|
|
meetingUrl: googleHangoutLink,
|
|
};
|
|
|
|
// Also create a new referenceToCreate with type video for google_meet
|
|
updateManager.referencesToCreate.push({
|
|
type: "google_meet_video",
|
|
meetingUrl: googleHangoutLink,
|
|
uid: googleCalResult.uid,
|
|
credentialId: updateManager.referencesToCreate[googleCalIndex].credentialId,
|
|
});
|
|
} else if (googleCalResult && !googleHangoutLink) {
|
|
results.push({
|
|
...googleMeetResult,
|
|
success: false,
|
|
});
|
|
}
|
|
}
|
|
const createdOrUpdatedEvent = Array.isArray(results[0]?.updatedEvent)
|
|
? results[0]?.updatedEvent[0]
|
|
: (results[0]?.updatedEvent ?? results[0]?.createdEvent);
|
|
metadata.hangoutLink = createdOrUpdatedEvent?.hangoutLink;
|
|
metadata.conferenceData = createdOrUpdatedEvent?.conferenceData;
|
|
metadata.entryPoints = createdOrUpdatedEvent?.entryPoints;
|
|
evt.appsStatus = handleAppsStatus(results, booking, reqAppsStatus);
|
|
videoCallUrl =
|
|
metadata.hangoutLink ||
|
|
createdOrUpdatedEvent?.url ||
|
|
organizerOrFirstDynamicGroupMemberDefaultLocationUrl ||
|
|
getVideoCallUrlFromCalEvent(evt) ||
|
|
videoCallUrl;
|
|
}
|
|
|
|
const calendarResult = results.find((result) => result.type.includes("_calendar"));
|
|
|
|
evt.iCalUID = Array.isArray(calendarResult?.updatedEvent)
|
|
? calendarResult?.updatedEvent[0]?.iCalUID
|
|
: calendarResult?.updatedEvent?.iCalUID || undefined;
|
|
}
|
|
|
|
evt.appsStatus = handleAppsStatus(results, booking, reqAppsStatus);
|
|
|
|
if (!noEmail && isConfirmedByDefault && !isDryRun) {
|
|
await emailsAndSmsHandler.send({
|
|
action: BookingActionMap.rescheduled,
|
|
data: {
|
|
evt,
|
|
eventType,
|
|
additionalInformation: metadata,
|
|
additionalNotes,
|
|
iCalUID,
|
|
originalRescheduledBooking,
|
|
rescheduleReason,
|
|
isRescheduledByBooker: reqBody.rescheduledBy === bookerEmail,
|
|
users,
|
|
changedOrganizer,
|
|
},
|
|
});
|
|
bookingEmailsAndSmsTaskerAction = BookingActionMap.rescheduled;
|
|
}
|
|
// If it's not a reschedule, doesn't require confirmation and there's no price,
|
|
// Create a booking
|
|
} else if (isConfirmedByDefault) {
|
|
const shouldSkipCalendarEvents = !areCalendarEventsEnabled || skipCalendarSyncTaskCreation;
|
|
const createManager = await eventManager.create(evt, { skipCalendarEvent: shouldSkipCalendarEvents });
|
|
if (evt.location) {
|
|
booking.location = evt.location;
|
|
}
|
|
// This gets overridden when creating the event - to check if notes have been hidden or not. We just reset this back
|
|
// to the default description when we are sending the emails.
|
|
evt.description = eventType.description;
|
|
|
|
results = createManager.results;
|
|
referencesToCreate = createManager.referencesToCreate;
|
|
videoCallUrl = evt.videoCallData && evt.videoCallData.url ? evt.videoCallData.url : null;
|
|
|
|
if (results.length > 0 && results.every((res) => !res.success)) {
|
|
const error = {
|
|
errorCode: "BookingCreatingMeetingFailed",
|
|
message: "Booking failed",
|
|
};
|
|
|
|
tracingLogger.error(
|
|
`EventManager.create failure in some of the integrations ${organizerUser.username}`,
|
|
safeStringify({ error, results })
|
|
);
|
|
} else {
|
|
const additionalInformation: AdditionalInformation = {};
|
|
|
|
if (results.length) {
|
|
// Handle Google Meet results
|
|
// We use the original booking location since the evt location changes to daily
|
|
if (bookingLocation === MeetLocationType) {
|
|
const googleMeetResult = {
|
|
appName: GoogleMeetMetadata.name,
|
|
type: "conferencing",
|
|
uid: results[0].uid,
|
|
originalEvent: results[0].originalEvent,
|
|
};
|
|
|
|
// Find index of google_calendar inside createManager.referencesToCreate
|
|
const googleCalIndex = createManager.referencesToCreate.findIndex(
|
|
(ref) => ref.type === "google_calendar"
|
|
);
|
|
const googleCalResult = results[googleCalIndex];
|
|
|
|
if (!googleCalResult) {
|
|
tracingLogger.warn("Google Calendar not installed but using Google Meet as location");
|
|
results.push({
|
|
...googleMeetResult,
|
|
success: false,
|
|
calWarnings: [tOrganizer("google_meet_warning")],
|
|
});
|
|
}
|
|
|
|
if (googleCalResult?.createdEvent?.hangoutLink) {
|
|
results.push({
|
|
...googleMeetResult,
|
|
success: true,
|
|
});
|
|
|
|
// Add google_meet to referencesToCreate in the same index as google_calendar
|
|
createManager.referencesToCreate[googleCalIndex] = {
|
|
...createManager.referencesToCreate[googleCalIndex],
|
|
meetingUrl: googleCalResult.createdEvent.hangoutLink,
|
|
};
|
|
|
|
// Also create a new referenceToCreate with type video for google_meet
|
|
createManager.referencesToCreate.push({
|
|
type: "google_meet_video",
|
|
meetingUrl: googleCalResult.createdEvent.hangoutLink,
|
|
uid: googleCalResult.uid,
|
|
credentialId: createManager.referencesToCreate[googleCalIndex].credentialId,
|
|
});
|
|
} else if (googleCalResult && !googleCalResult.createdEvent?.hangoutLink) {
|
|
results.push({
|
|
...googleMeetResult,
|
|
success: false,
|
|
});
|
|
}
|
|
}
|
|
// TODO: Handle created event metadata more elegantly
|
|
additionalInformation.hangoutLink = results[0].createdEvent?.hangoutLink;
|
|
additionalInformation.conferenceData = results[0].createdEvent?.conferenceData;
|
|
additionalInformation.entryPoints = results[0].createdEvent?.entryPoints;
|
|
evt.appsStatus = handleAppsStatus(results, booking, reqAppsStatus);
|
|
videoCallUrl =
|
|
additionalInformation.hangoutLink ||
|
|
organizerOrFirstDynamicGroupMemberDefaultLocationUrl ||
|
|
videoCallUrl;
|
|
|
|
if (!isDryRun && evt.iCalUID !== booking.iCalUID) {
|
|
// The eventManager could change the iCalUID. At this point we can update the DB record
|
|
await deps.prismaClient.booking.update({
|
|
where: {
|
|
id: booking.id,
|
|
},
|
|
data: {
|
|
iCalUID: evt.iCalUID || booking.iCalUID,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
if (!noEmail) {
|
|
if (!isDryRun && !(eventType.seatsPerTimeSlot && rescheduleUid)) {
|
|
await emailsAndSmsHandler.send({
|
|
action: BookingActionMap.confirmed,
|
|
data: {
|
|
eventType: {
|
|
metadata: eventType.metadata,
|
|
schedulingType: eventType.schedulingType,
|
|
},
|
|
eventNameObject,
|
|
workflows,
|
|
evt,
|
|
additionalInformation,
|
|
additionalNotes,
|
|
customInputs,
|
|
},
|
|
});
|
|
bookingEmailsAndSmsTaskerAction = BookingActionMap.confirmed;
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// If isConfirmedByDefault is false, then booking can't be considered ACCEPTED and thus EventManager has no role to play. Booking is created as PENDING
|
|
tracingLogger.debug(
|
|
`EventManager doesn't need to create or reschedule event for booking ${organizerUser.username}`,
|
|
safeStringify({
|
|
calEvent: getPiiFreeCalendarEvent(evt),
|
|
isConfirmedByDefault,
|
|
paymentValue: paymentAppData.price,
|
|
})
|
|
);
|
|
}
|
|
|
|
const bookingRequiresPayment =
|
|
!Number.isNaN(paymentAppData.price) &&
|
|
paymentAppData.price > 0 &&
|
|
!originalRescheduledBooking?.paid &&
|
|
!!booking;
|
|
|
|
if (!isConfirmedByDefault && noEmail !== true && !bookingRequiresPayment) {
|
|
tracingLogger.debug(
|
|
`Emails: Booking ${organizerUser.username} requires confirmation, sending request emails`,
|
|
safeStringify({
|
|
calEvent: getPiiFreeCalendarEvent(evt),
|
|
})
|
|
);
|
|
if (!isDryRun) {
|
|
await emailsAndSmsHandler.send({
|
|
action: BookingActionMap.requested,
|
|
data: { evt, attendees: attendeesList, eventType, additionalNotes },
|
|
});
|
|
bookingEmailsAndSmsTaskerAction = BookingActionMap.requested;
|
|
}
|
|
}
|
|
|
|
if (booking.location?.startsWith("http")) {
|
|
videoCallUrl = booking.location;
|
|
}
|
|
|
|
const metadata = videoCallUrl
|
|
? {
|
|
videoCallUrl: getVideoCallUrlFromCalEvent(evt) || videoCallUrl,
|
|
}
|
|
: undefined;
|
|
|
|
// Query feature flags in parallel before firing booking events
|
|
// TODO: We should support checkIfOrgHasFeatures - Bulk plus orgId check, that would be more efficient.
|
|
const [isBookingEmailSmsTaskerEnabled, isBookingAuditEnabled] = orgId
|
|
? await Promise.all([
|
|
deps.featuresRepository.checkIfTeamHasFeature(orgId, "booking-email-sms-tasker"),
|
|
deps.featuresRepository.checkIfTeamHasFeature(orgId, "booking-audit"),
|
|
])
|
|
: [false, false];
|
|
|
|
await this.fireBookingEvents({
|
|
booking: {
|
|
...booking,
|
|
userEmail: booking.user?.email ?? null,
|
|
},
|
|
organizerUser,
|
|
// FIXME: It looks like hasHashedBookingLink is set to true based on the value of hashedLink when sending the request. So, technically we could remove hasHashedBookingLink usage completely
|
|
hashedLink: hasHashedBookingLink ? (reqBody.hashedLink ?? null) : null,
|
|
isDryRun,
|
|
eventOrganizationId,
|
|
bookerEmail,
|
|
bookerName: fullName,
|
|
actorUserUuid: userUuid ?? null,
|
|
originalRescheduledBooking,
|
|
rescheduledBy: reqBody.rescheduledBy ?? null,
|
|
actionSource,
|
|
isRecurringBooking: !!input.bookingData.allRecurringDates,
|
|
attendeeSeatId: evt.attendeeSeatId ?? null,
|
|
tracingLogger,
|
|
isBookingAuditEnabled,
|
|
});
|
|
|
|
const webhookLocation = metadata?.videoCallUrl || evt.location;
|
|
|
|
const { assignmentReason: _emailAssignmentReason, ...evtWithoutAssignmentReason } = evt;
|
|
const webhookData: EventPayloadType = {
|
|
...evtWithoutAssignmentReason,
|
|
...eventTypeInfo,
|
|
bookingId: booking?.id,
|
|
rescheduleId: originalRescheduledBooking?.id || undefined,
|
|
rescheduleUid,
|
|
rescheduleStartTime: originalRescheduledBooking?.startTime
|
|
? dayjs(originalRescheduledBooking?.startTime).utc().format()
|
|
: undefined,
|
|
rescheduleEndTime: originalRescheduledBooking?.endTime
|
|
? dayjs(originalRescheduledBooking?.endTime).utc().format()
|
|
: undefined,
|
|
metadata: { ...metadata, ...reqBody.metadata },
|
|
eventTypeId,
|
|
status: "ACCEPTED",
|
|
smsReminderNumber: booking?.smsReminderNumber || undefined,
|
|
rescheduledBy: reqBody.rescheduledBy,
|
|
location: webhookLocation,
|
|
...(assignmentReason ? { assignmentReason: [assignmentReason] } : {}),
|
|
};
|
|
|
|
if (bookingRequiresPayment) {
|
|
tracingLogger.debug(`Booking ${organizerUser.username} requires payment`);
|
|
// Load credentials.app.categories
|
|
const credentialPaymentAppCategories = await deps.prismaClient.credential.findMany({
|
|
where: {
|
|
...(paymentAppData.credentialId ? { id: paymentAppData.credentialId } : { userId: organizerUser.id }),
|
|
app: {
|
|
categories: {
|
|
hasSome: ["payment"],
|
|
},
|
|
},
|
|
},
|
|
select: {
|
|
key: true,
|
|
appId: true,
|
|
app: {
|
|
select: {
|
|
categories: true,
|
|
dirName: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
const eventTypePaymentAppCredential = credentialPaymentAppCategories.find((credential) => {
|
|
return credential.appId === paymentAppData.appId;
|
|
});
|
|
|
|
if (!eventTypePaymentAppCredential) {
|
|
throw new HttpError({
|
|
statusCode: 400,
|
|
message: "Missing payment credentials",
|
|
});
|
|
}
|
|
|
|
// Convert type of eventTypePaymentAppCredential to appId: EventTypeAppList
|
|
if (!booking.user) booking.user = organizerUser;
|
|
const payment = await handlePayment({
|
|
evt,
|
|
selectedEventType: {
|
|
...eventType,
|
|
metadata: eventType.metadata
|
|
? {
|
|
...eventType.metadata,
|
|
apps: eventType.metadata?.apps as Prisma.JsonValue,
|
|
}
|
|
: {},
|
|
},
|
|
paymentAppCredentials: eventTypePaymentAppCredential as IEventTypePaymentCredentialType,
|
|
booking,
|
|
bookerName: fullName,
|
|
bookerEmail,
|
|
bookerPhoneNumber,
|
|
isDryRun,
|
|
bookingFields: eventType.bookingFields,
|
|
locale: language,
|
|
});
|
|
const subscriberOptionsPaymentInitiated: GetSubscriberOptions = {
|
|
userId: triggerForUser ? organizerUser.id : null,
|
|
eventTypeId,
|
|
triggerEvent: WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED,
|
|
teamId,
|
|
orgId,
|
|
oAuthClientId: platformClientId,
|
|
};
|
|
await handleWebhookTrigger({
|
|
subscriberOptions: subscriberOptionsPaymentInitiated,
|
|
eventTrigger: WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED,
|
|
webhookData: {
|
|
...webhookData,
|
|
paymentId: payment?.id,
|
|
},
|
|
isDryRun,
|
|
traceContext,
|
|
});
|
|
|
|
try {
|
|
const calendarEventForWorkflow = {
|
|
...evt,
|
|
rescheduleReason,
|
|
metadata,
|
|
eventType: {
|
|
slug: eventType.slug,
|
|
schedulingType: eventType.schedulingType,
|
|
hosts: eventType.hosts,
|
|
},
|
|
bookerUrl,
|
|
};
|
|
|
|
if (isNormalBookingOrFirstRecurringSlot) {
|
|
const creditService = new CreditService();
|
|
|
|
await WorkflowService.scheduleWorkflowsFilteredByTriggerEvent({
|
|
workflows,
|
|
smsReminderNumber: smsReminderNumber || null,
|
|
calendarEvent: calendarEventForWorkflow,
|
|
hideBranding: !!eventType.owner?.hideBranding || !!platformClientId,
|
|
seatReferenceUid: evt.attendeeSeatId,
|
|
isDryRun,
|
|
triggers: [WorkflowTriggerEvents.BOOKING_PAYMENT_INITIATED],
|
|
creditCheckFn: creditService.hasAvailableCredits.bind(creditService),
|
|
});
|
|
}
|
|
} catch (error) {
|
|
tracingLogger.error(
|
|
"Error while scheduling workflow reminders for booking payment initiated",
|
|
JSON.stringify({ error })
|
|
);
|
|
}
|
|
|
|
// TODO: Refactor better so this booking object is not passed
|
|
// all around and instead the individual fields are sent as args.
|
|
const bookingResponse = {
|
|
...booking,
|
|
user: {
|
|
...booking.user,
|
|
email: null,
|
|
},
|
|
videoCallUrl: metadata?.videoCallUrl,
|
|
// Ensure seatReferenceUid is properly typed as string | null
|
|
seatReferenceUid: evt.attendeeSeatId,
|
|
};
|
|
|
|
return {
|
|
...bookingResponse,
|
|
...luckyUserResponse,
|
|
message: "Payment required",
|
|
paymentRequired: true,
|
|
paymentUid: payment?.uid,
|
|
paymentId: payment?.id,
|
|
isDryRun,
|
|
...(isDryRun ? { troubleshooterData } : {}),
|
|
organizationId: eventOrganizationId,
|
|
previousBooking: originalRescheduledBooking
|
|
? {
|
|
uid: originalRescheduledBooking.uid,
|
|
startTime: originalRescheduledBooking.startTime,
|
|
endTime: originalRescheduledBooking.endTime,
|
|
}
|
|
: null,
|
|
};
|
|
}
|
|
|
|
tracingLogger.debug(`Booking ${organizerUser.username} completed`);
|
|
|
|
// We are here so, booking doesn't require payment and booking is also created in DB already, through createBooking call
|
|
if (isConfirmedByDefault) {
|
|
const subscribersMeetingEnded = await getWebhooks(subscriberOptionsMeetingEnded);
|
|
const subscribersMeetingStarted = await getWebhooks(subscriberOptionsMeetingStarted);
|
|
|
|
const deleteWebhookScheduledTriggerPromises: Promise<unknown>[] = [];
|
|
const scheduleTriggerPromises = [];
|
|
|
|
if (rescheduleUid && originalRescheduledBooking) {
|
|
//delete all scheduled triggers for meeting ended and meeting started of booking
|
|
deleteWebhookScheduledTriggerPromises.push(
|
|
deleteWebhookScheduledTriggers({
|
|
booking: originalRescheduledBooking,
|
|
isDryRun,
|
|
})
|
|
);
|
|
deleteWebhookScheduledTriggerPromises.push(
|
|
cancelNoShowTasksForBooking({
|
|
bookingUid: originalRescheduledBooking.uid,
|
|
})
|
|
);
|
|
}
|
|
|
|
if (booking && booking.status === BookingStatus.ACCEPTED) {
|
|
const bookingWithCalEventResponses = {
|
|
...booking,
|
|
responses: reqBody.calEventResponses,
|
|
};
|
|
for (const subscriber of subscribersMeetingEnded) {
|
|
scheduleTriggerPromises.push(
|
|
scheduleTrigger({
|
|
booking: bookingWithCalEventResponses,
|
|
subscriberUrl: subscriber.subscriberUrl,
|
|
subscriber,
|
|
triggerEvent: WebhookTriggerEvents.MEETING_ENDED,
|
|
isDryRun,
|
|
})
|
|
);
|
|
}
|
|
|
|
for (const subscriber of subscribersMeetingStarted) {
|
|
scheduleTriggerPromises.push(
|
|
scheduleTrigger({
|
|
booking: bookingWithCalEventResponses,
|
|
subscriberUrl: subscriber.subscriberUrl,
|
|
subscriber,
|
|
triggerEvent: WebhookTriggerEvents.MEETING_STARTED,
|
|
isDryRun,
|
|
})
|
|
);
|
|
}
|
|
}
|
|
|
|
const scheduledTriggerResults = await Promise.allSettled([
|
|
...deleteWebhookScheduledTriggerPromises,
|
|
...scheduleTriggerPromises,
|
|
]);
|
|
const failures = scheduledTriggerResults.filter((result) => result.status === "rejected");
|
|
|
|
if (failures.length > 0) {
|
|
tracingLogger.error(
|
|
"Error while scheduling or canceling webhook triggers",
|
|
safeStringify({
|
|
errors: failures.map((f) => f.reason),
|
|
})
|
|
);
|
|
}
|
|
|
|
// Send Webhook call if hooked to BOOKING_CREATED & BOOKING_RESCHEDULED
|
|
await handleWebhookTrigger({
|
|
subscriberOptions,
|
|
eventTrigger,
|
|
webhookData,
|
|
isDryRun,
|
|
traceContext,
|
|
});
|
|
}
|
|
|
|
if (!booking) throw new HttpError({ statusCode: 400, message: "Booking failed" });
|
|
|
|
try {
|
|
if (!isDryRun) {
|
|
await deps.prismaClient.booking.update({
|
|
where: {
|
|
uid: booking.uid,
|
|
},
|
|
data: {
|
|
location: evt.location,
|
|
metadata: { ...(typeof booking.metadata === "object" && booking.metadata), ...metadata },
|
|
references: {
|
|
createMany: {
|
|
data: referencesToCreate,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
} catch (error) {
|
|
tracingLogger.error("Error while creating booking references", JSON.stringify({ error }));
|
|
}
|
|
|
|
// Queue BOOKING_REQUESTED webhook after booking update so consumer fetches booking with location, metadata, references
|
|
if (booking && booking.status === BookingStatus.PENDING && !isDryRun) {
|
|
try {
|
|
await deps.webhookProducer.queueBookingRequestedWebhook({
|
|
bookingUid: booking.uid,
|
|
userId: subscriberOptions.userId ?? undefined,
|
|
eventTypeId: subscriberOptions.eventTypeId ?? undefined,
|
|
teamId: Array.isArray(subscriberOptions.teamId)
|
|
? subscriberOptions.teamId[0]
|
|
: (subscriberOptions.teamId ?? undefined),
|
|
orgId: subscriberOptions.orgId ?? undefined,
|
|
oAuthClientId: platformClientId ?? undefined,
|
|
});
|
|
} catch (webhookError) {
|
|
tracingLogger.error(
|
|
`Error queueing BOOKING_REQUESTED webhook: bookingId: ${booking.id}, bookingUid: ${booking.uid}`,
|
|
safeStringify(webhookError)
|
|
);
|
|
}
|
|
}
|
|
|
|
const evtWithMetadata = {
|
|
...evt,
|
|
rescheduleReason,
|
|
metadata,
|
|
eventType: { slug: eventType.slug, schedulingType: eventType.schedulingType, hosts: eventType.hosts },
|
|
bookerUrl,
|
|
};
|
|
|
|
if (!eventType.metadata?.disableStandardEmails?.all?.attendee) {
|
|
await scheduleMandatoryReminder({
|
|
evt: evtWithMetadata,
|
|
workflows,
|
|
requiresConfirmation: !isConfirmedByDefault,
|
|
hideBranding: !!eventType.owner?.hideBranding || !!platformClientId,
|
|
seatReferenceUid: evt.attendeeSeatId,
|
|
isPlatformNoEmail: noEmail && Boolean(platformClientId),
|
|
isDryRun,
|
|
traceContext,
|
|
});
|
|
}
|
|
|
|
try {
|
|
const creditService = new CreditService();
|
|
|
|
await WorkflowService.scheduleWorkflowsForNewBooking({
|
|
workflows,
|
|
smsReminderNumber: smsReminderNumber || null,
|
|
calendarEvent: evtWithMetadata,
|
|
hideBranding: !!eventType.owner?.hideBranding || !!platformClientId,
|
|
seatReferenceUid: evt.attendeeSeatId,
|
|
isDryRun,
|
|
isConfirmedByDefault,
|
|
isNormalBookingOrFirstRecurringSlot,
|
|
isRescheduleEvent: !!rescheduleUid,
|
|
creditCheckFn: creditService.hasAvailableCredits.bind(creditService),
|
|
});
|
|
} catch (error) {
|
|
tracingLogger.error("Error while scheduling workflow reminders", JSON.stringify({ error }));
|
|
}
|
|
|
|
try {
|
|
if (isConfirmedByDefault) {
|
|
await scheduleNoShowTriggers({
|
|
booking: {
|
|
startTime: booking.startTime,
|
|
id: booking.id,
|
|
location: booking.location,
|
|
uid: booking.uid,
|
|
},
|
|
triggerForUser,
|
|
organizerUser: { id: organizerUser.id },
|
|
eventTypeId,
|
|
teamId,
|
|
orgId,
|
|
isDryRun,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
tracingLogger.error("Error while scheduling no show triggers", JSON.stringify({ error }));
|
|
}
|
|
|
|
if (!isDryRun) {
|
|
await handleAnalyticsEvents({
|
|
credentials: allCredentials,
|
|
rawBookingData,
|
|
bookingInfo: {
|
|
name: fullName,
|
|
email: bookerEmail,
|
|
eventName: "Cal.com lead",
|
|
},
|
|
isTeamEventType,
|
|
});
|
|
|
|
// Unused until we deploy to trigger.dev production
|
|
// for now we only enable for cal.com org and we keep our current email system
|
|
// cal.com org members will see emails in double while we test
|
|
if (ENABLE_ASYNC_TASKER && !noEmail && isBookingEmailSmsTaskerEnabled) {
|
|
try {
|
|
await deps.bookingEmailAndSmsTasker.send({
|
|
action: bookingEmailsAndSmsTaskerAction,
|
|
schedulingType: evtWithMetadata.eventType.schedulingType,
|
|
payload: {
|
|
bookingId: booking.id,
|
|
conferenceCredentialId,
|
|
platformClientId,
|
|
platformRescheduleUrl,
|
|
platformCancelUrl,
|
|
platformBookingUrl,
|
|
isRescheduledByBooker: reqBody.rescheduledBy === bookerEmail,
|
|
},
|
|
});
|
|
} catch (err) {
|
|
tracingLogger.error("bookingEmailAndSmsTasker error:", err);
|
|
}
|
|
}
|
|
}
|
|
|
|
// TODO: Refactor better so this booking object is not passed
|
|
// all around and instead the individual fields are sent as args.
|
|
const bookingResponse = {
|
|
...booking,
|
|
user: {
|
|
...booking.user,
|
|
email: null,
|
|
},
|
|
paymentRequired: false,
|
|
};
|
|
|
|
return {
|
|
...bookingResponse,
|
|
...luckyUserResponse,
|
|
isDryRun,
|
|
...(isDryRun ? { troubleshooterData } : {}),
|
|
references: referencesToCreate,
|
|
seatReferenceUid: evt.attendeeSeatId,
|
|
videoCallUrl: metadata?.videoCallUrl,
|
|
organizationId: eventOrganizationId,
|
|
previousBooking: originalRescheduledBooking
|
|
? {
|
|
uid: originalRescheduledBooking.uid,
|
|
startTime: originalRescheduledBooking.startTime,
|
|
endTime: originalRescheduledBooking.endTime,
|
|
}
|
|
: null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Takes care of creating/rescheduling non-recurring, non-instant bookings. Such bookings could be TeamBooking, UserBooking, SeatedUserBooking, SeatedTeamBooking, etc.
|
|
* We can't name it CoreBookingService because non-instant booking also creates a booking but it is entirely different from the regular booking.
|
|
* We are open to renaming it to something more descriptive.
|
|
*/
|
|
export class RegularBookingService implements IBookingService {
|
|
constructor(private readonly deps: IBookingServiceDependencies) {}
|
|
|
|
async fireBookingEvents({
|
|
booking,
|
|
organizerUser,
|
|
hashedLink,
|
|
isDryRun,
|
|
eventOrganizationId,
|
|
bookerEmail,
|
|
bookerName,
|
|
actorUserUuid,
|
|
originalRescheduledBooking,
|
|
rescheduledBy,
|
|
actionSource,
|
|
isRecurringBooking,
|
|
attendeeSeatId,
|
|
tracingLogger,
|
|
isBookingAuditEnabled,
|
|
}: {
|
|
booking: {
|
|
id: number;
|
|
uid: string;
|
|
startTime: Date;
|
|
endTime: Date;
|
|
status: BookingStatus;
|
|
userId: number | null;
|
|
attendees?: Array<{ id: number; email: string }>;
|
|
userUuid: string | null;
|
|
userEmail: string | null;
|
|
};
|
|
organizerUser: { id: number; uuid: string };
|
|
hashedLink: string | null;
|
|
isDryRun: boolean;
|
|
eventOrganizationId: number | null;
|
|
bookerEmail: string;
|
|
bookerName: string;
|
|
rescheduledBy: string | null;
|
|
actorUserUuid: string | null;
|
|
originalRescheduledBooking: BookingType | null;
|
|
actionSource: ActionSource;
|
|
isRecurringBooking: boolean;
|
|
tracingLogger: ReturnType<typeof distributedTracing.getTracingLogger>;
|
|
attendeeSeatId: string | null;
|
|
isBookingAuditEnabled: boolean;
|
|
}) {
|
|
try {
|
|
const bookingCreatedPayload = buildBookingCreatedPayload({
|
|
booking,
|
|
organizerUserId: organizerUser.id,
|
|
organizerUserUuid: organizerUser.uuid,
|
|
hashedLink,
|
|
isDryRun,
|
|
organizationId: eventOrganizationId,
|
|
});
|
|
|
|
const bookingEventHandler = this.deps.bookingEventHandler;
|
|
const bookerAttendeeId = booking.attendees?.find((attendee) => attendee.email === bookerEmail)?.id;
|
|
const rescheduledByAttendeeId = booking.attendees?.find(
|
|
(attendee) => attendee.email === rescheduledBy
|
|
)?.id;
|
|
const rescheduledByUserUuid = booking.userEmail === rescheduledBy ? booking.userUuid : null;
|
|
const auditActor = getBookingAuditActorForNewBooking({
|
|
bookerAttendeeId: bookerAttendeeId ?? null,
|
|
actorUserUuid,
|
|
bookerEmail,
|
|
bookerName,
|
|
rescheduledBy: rescheduledBy
|
|
? {
|
|
attendeeId: rescheduledByAttendeeId ?? null,
|
|
userUuid: rescheduledByUserUuid ?? null,
|
|
email: rescheduledBy,
|
|
}
|
|
: null,
|
|
logger: tracingLogger,
|
|
});
|
|
|
|
// For recurring bookings we fire the events in the RecurringBookingService
|
|
if (!isRecurringBooking) {
|
|
if (originalRescheduledBooking) {
|
|
const bookingRescheduledPayload: BookingRescheduledPayload = {
|
|
...bookingCreatedPayload,
|
|
oldBooking: {
|
|
uid: originalRescheduledBooking.uid,
|
|
startTime: originalRescheduledBooking.startTime,
|
|
endTime: originalRescheduledBooking.endTime,
|
|
},
|
|
};
|
|
await bookingEventHandler.onBookingRescheduled({
|
|
payload: bookingRescheduledPayload,
|
|
actor: auditActor,
|
|
auditData: buildBookingRescheduledAuditData({
|
|
oldBooking: originalRescheduledBooking,
|
|
newBooking: booking,
|
|
}),
|
|
source: actionSource,
|
|
operationId: null,
|
|
isBookingAuditEnabled,
|
|
});
|
|
} else {
|
|
await bookingEventHandler.onBookingCreated({
|
|
payload: bookingCreatedPayload,
|
|
actor: auditActor,
|
|
auditData: buildBookingCreatedAuditData({ booking, attendeeSeatId }),
|
|
source: actionSource,
|
|
operationId: null,
|
|
isBookingAuditEnabled,
|
|
});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
tracingLogger.error("Error while firing booking events", safeStringify(error));
|
|
}
|
|
}
|
|
|
|
async createBooking(input: { bookingData: CreateRegularBookingData; bookingMeta?: CreateBookingMeta }) {
|
|
return handler.bind(this)({ bookingData: input.bookingData, ...input.bookingMeta }, this.deps);
|
|
}
|
|
|
|
async rescheduleBooking(input: { bookingData: CreateRegularBookingData; bookingMeta?: CreateBookingMeta }) {
|
|
return handler.bind(this)({ bookingData: input.bookingData, ...input.bookingMeta }, this.deps);
|
|
}
|
|
|
|
/**
|
|
* @deprecated Exists only till API v1 is removed.
|
|
*/
|
|
async createBookingForApiV1(input: {
|
|
bookingData: CreateRegularBookingData;
|
|
bookingMeta?: CreateBookingMeta;
|
|
bookingDataSchemaGetter: BookingDataSchemaGetter;
|
|
}) {
|
|
const bookingMeta = input.bookingMeta ?? {};
|
|
return handler.bind(this)(
|
|
{
|
|
bookingData: input.bookingData,
|
|
...bookingMeta,
|
|
},
|
|
this.deps,
|
|
input.bookingDataSchemaGetter
|
|
);
|
|
}
|
|
}
|