mirror of
https://github.com/hanzoai/calendar.git
synced 2026-10-30 14:46:43 +00:00
e04a394e1c
* fix: remove IS_PRODUCTION gate from BookingAuditProducer Remove the IS_PRODUCTION check that was preventing booking audits from being queued in production. Audits are still properly gated by: 1. Organization check: Audits are skipped for non-organization bookings (organizationId === null) 2. Feature flag: The BookingAuditTaskConsumer checks if the 'booking-audit' feature is enabled for the organization via featuresRepository.checkIfTeamHasFeature() The IS_PRODUCTION gate was intentionally added to prevent logs from being created in production while the action data versioning was being actively reviewed and finalized. Without proper versioning handling, the Booking History UI could crash when encountering unversioned data. Now that the versioning system is in place, this gate can be safely removed. Co-Authored-By: [email protected] <[email protected]> * fix: revert formatting, keep only IS_PRODUCTION removal Reverts the unintended formatting changes from the previous commit. Only removes the IS_PRODUCTION gate without changing indentation. Co-Authored-By: [email protected] <[email protected]> * feat: add booking-audit feature flag check in producer to avoid unnecessary task creation - Query booking-audit and booking-email-sms-tasker flags in parallel before fireBookingEvents - Pass isBookingAuditEnabled through BookingEventHandler to producer's queueTask method - Add conditional check in queueTask with debug log when skipping audit - Reuse pre-queried isBookingEmailSmsTaskerEnabled flag instead of querying again Co-Authored-By: [email protected] <[email protected]> * feat: make isBookingAuditEnabled required and pass it through all booking audit flows - Make isBookingAuditEnabled a required property in BookingAuditProducerService interface - Update all BookingEventHandler methods to require isBookingAuditEnabled - Add feature flag check in all flows that call booking audit: - handleSeats (seat booking/rescheduling) - RecurringBookingService (bulk bookings) - handleCancelBooking (booking cancellation) - handleConfirmation (booking acceptance) - roundRobinReassignment (automatic reassignment) - roundRobinManualReassignment (manual reassignment) - trpc handlers: addGuests, confirm, editLocation, requestReschedule - Skip queueing audit tasks when feature is disabled with debug logging Co-Authored-By: [email protected] <[email protected]> * fix: add featuresRepository dependency to RecurringBookingService DI module Co-Authored-By: [email protected] <[email protected]> * refactor: make isBookingAuditEnabled optional for non-main flows Keep feature flag check only in main flows (handleSeats, RegularBookingService, RecurringBookingService) which are frequently triggered. For other flows (handleCancelBooking, handleConfirmation, roundRobinReassignment, etc.), rely on the existing consumer-level check. Changes: - Revert feature flag check from non-main flows - Make isBookingAuditEnabled optional in interface for non-main flow methods - Keep isBookingAuditEnabled required for main flow methods (queueCreatedAudit, queueRescheduledAudit, queueSeatBookedAudit, queueSeatRescheduledAudit, queueBulkCreatedAudit, queueBulkRescheduledAudit) - Update BookingEventHandlerService to use required params for main flows and optional for non-main flows Co-Authored-By: [email protected] <[email protected]> * refactor: remove isBookingAuditEnabled from non-main flow methods Co-Authored-By: [email protected] <[email protected]> * feat: make isBookingAuditEnabled required in all BookingEventHandlerService methods - Add isBookingAuditEnabled as required parameter in all BookingAuditProducerService interface methods - Update BookingAuditTaskerProducerService to use simplified check (!params.isBookingAuditEnabled) - Update BookingEventHandlerService to require isBookingAuditEnabled in all methods - Update all callers to query booking-audit feature flag and pass isBookingAuditEnabled: - handleCancelBooking - handleConfirmation - roundRobinReassignment - roundRobinManualReassignment - addGuests.handler - confirm.handler - editLocation.handler - requestReschedule.handler - Inject featuresRepository in API V2's booking-location.service.ts Co-Authored-By: [email protected] <[email protected]> * test: update roundRobinReassignment tests to include isBookingAuditEnabled Co-Authored-By: [email protected] <[email protected]> * test: update roundRobinManualReassignment tests to include isBookingAuditEnabled Co-Authored-By: [email protected] <[email protected]> * fix: inject featuresRepository in API V2 RecurringBookingService Co-Authored-By: [email protected] <[email protected]> * fix: import PrismaWorkerModule for PrismaFeaturesRepository dependency Co-Authored-By: [email protected] <[email protected]> * fix: use booking's organization for feature flag check in addGuests handler Use booking.user?.profiles?.[0]?.organizationId instead of user.organizationId to check the booking-audit feature flag. This ensures the feature flag is checked against the booking's organization rather than the actor's organization, which is consistent with other handlers in this PR. Addresses Cubic AI review feedback (confidence 9/10). Co-Authored-By: unknown <> * Add comment * fix: use user.organizationId for feature flag check in addGuests handler Co-Authored-By: [email protected] <[email protected]> * fix: use booking's organizationId for feature flag check in addGuests handler Co-Authored-By: [email protected] <[email protected]> * fix: revert to user.organizationId for feature flag check in addGuests handler Co-Authored-By: [email protected] <[email protected]> * fix: add isBookingAuditEnabled to onNoShowUpdated calls after main merge Co-Authored-By: [email protected] <[email protected]> * fix: add isBookingAuditEnabled to onNoShowUpdated calls and update tests Co-Authored-By: [email protected] <[email protected]> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
340 lines
11 KiB
TypeScript
340 lines
11 KiB
TypeScript
import { getUsersCredentialsIncludeServiceAccountKey } from "@calcom/app-store/delegationCredential";
|
|
import { getEventLocationType, OrganizerDefaultConferencingAppType } from "@calcom/app-store/locations";
|
|
import { getAppFromSlug } from "@calcom/app-store/utils";
|
|
import { sendLocationChangeEmailsAndSMS } from "@calcom/emails/email-manager";
|
|
import { makeUserActor } from "@calcom/features/booking-audit/lib/makeActor";
|
|
import type { ValidActionSource } from "@calcom/features/booking-audit/lib/types/actionSource";
|
|
import { getBookingEventHandlerService } from "@calcom/features/bookings/di/BookingEventHandlerService.container";
|
|
import { getFeaturesRepository } from "@calcom/features/di/containers/FeaturesRepository";
|
|
import EventManager from "@calcom/features/bookings/lib/EventManager";
|
|
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
|
|
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
|
|
import { CredentialAccessService } from "@calcom/features/credentials/services/CredentialAccessService";
|
|
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
|
import { buildCalEventFromBooking } from "@calcom/lib/buildCalEventFromBooking";
|
|
import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser";
|
|
import logger from "@calcom/lib/logger";
|
|
import { safeStringify } from "@calcom/lib/safeStringify";
|
|
import { getTranslation } from "@calcom/lib/server/i18n";
|
|
import { prisma } from "@calcom/prisma";
|
|
import type { Booking, BookingReference } from "@calcom/prisma/client";
|
|
import type { EventTypeMetadata, userMetadata } from "@calcom/prisma/zod-utils";
|
|
import type { AdditionalInformation, CalendarEvent } from "@calcom/types/Calendar";
|
|
import type { PartialReference } from "@calcom/types/EventManager";
|
|
import type { Ensure } from "@calcom/types/utils";
|
|
import { TRPCError } from "@trpc/server";
|
|
import type { z } from "zod";
|
|
|
|
import type { TrpcSessionUser } from "../../../types";
|
|
import type { TEditLocationInputSchema } from "./editLocation.schema";
|
|
import type { BookingsProcedureContext } from "./util";
|
|
|
|
// #region EditLocation Types and Helpers
|
|
type EditLocationOptions = {
|
|
ctx: {
|
|
user: NonNullable<TrpcSessionUser>;
|
|
} & BookingsProcedureContext;
|
|
input: TEditLocationInputSchema;
|
|
actionSource: ValidActionSource;
|
|
};
|
|
|
|
type UserMetadata = z.infer<typeof userMetadata>;
|
|
|
|
async function updateLocationInConnectedAppForBooking({
|
|
evt,
|
|
eventManager,
|
|
booking,
|
|
}: {
|
|
evt: CalendarEvent;
|
|
eventManager: EventManager;
|
|
booking: Booking & {
|
|
references: BookingReference[];
|
|
};
|
|
}) {
|
|
const updatedResult = await eventManager.updateLocation(evt, booking);
|
|
const results = updatedResult.results;
|
|
if (results.length > 0 && results.every((res) => !res.success)) {
|
|
const error = {
|
|
errorCode: "BookingUpdateLocationFailed",
|
|
message: "Updating location failed",
|
|
};
|
|
logger.error(`Updating location failed`, safeStringify(error), safeStringify(results));
|
|
throw new SystemError("Updating location failed");
|
|
}
|
|
logger.info(`Got results from updateLocationInConnectedApp`, safeStringify(updatedResult.results));
|
|
return updatedResult;
|
|
}
|
|
|
|
function extractAdditionalInformation(result: {
|
|
updatedEvent: AdditionalInformation;
|
|
}): AdditionalInformation {
|
|
const additionalInformation: AdditionalInformation = {};
|
|
if (result) {
|
|
additionalInformation.hangoutLink = result.updatedEvent?.hangoutLink;
|
|
additionalInformation.conferenceData = result.updatedEvent?.conferenceData;
|
|
additionalInformation.entryPoints = result.updatedEvent?.entryPoints;
|
|
}
|
|
return additionalInformation;
|
|
}
|
|
|
|
async function updateBookingLocationInDb({
|
|
booking,
|
|
evt,
|
|
references,
|
|
}: {
|
|
booking: {
|
|
id: number;
|
|
metadata: Booking["metadata"];
|
|
responses: Booking["responses"];
|
|
};
|
|
evt: Ensure<CalendarEvent, "location">;
|
|
references: PartialReference[];
|
|
}) {
|
|
const isSeatedEvent = !!evt.seatsPerTimeSlot;
|
|
const bookingMetadataUpdate = {
|
|
videoCallUrl: getVideoCallUrlFromCalEvent(evt),
|
|
};
|
|
const referencesToCreate = references.map((reference) => {
|
|
const { credentialId, ...restReference } = reference;
|
|
return {
|
|
...restReference,
|
|
...(credentialId && credentialId > 0 ? { credentialId } : {}),
|
|
};
|
|
});
|
|
const responses = {
|
|
...(typeof booking.responses === "object" && booking.responses),
|
|
location: {
|
|
value: evt.location,
|
|
optionValue: "",
|
|
},
|
|
};
|
|
|
|
const bookingRepository = new BookingRepository(prisma);
|
|
await bookingRepository.updateLocationById({
|
|
where: { id: booking.id },
|
|
data: {
|
|
location: evt.location,
|
|
metadata: {
|
|
...(typeof booking.metadata === "object" && booking.metadata),
|
|
...bookingMetadataUpdate,
|
|
},
|
|
referencesToCreate,
|
|
...(!isSeatedEvent ? { responses } : {}),
|
|
iCalSequence: (evt.iCalSequence || 0) + 1,
|
|
},
|
|
});
|
|
return { updatedLocation: evt.location };
|
|
}
|
|
|
|
async function getAllCredentialsIncludeServiceAccountKey({
|
|
user,
|
|
conferenceCredentialId,
|
|
bookingOwnerId,
|
|
}: {
|
|
user: { id: number; email: string };
|
|
conferenceCredentialId: number | null;
|
|
bookingOwnerId: number | null;
|
|
}) {
|
|
const credentials = await getUsersCredentialsIncludeServiceAccountKey(user);
|
|
|
|
let conferenceCredential: Awaited<ReturnType<typeof CredentialRepository.findFirstByIdWithKeyAndUser>> | undefined;
|
|
|
|
if (conferenceCredentialId) {
|
|
// Validate that the credential is accessible before fetching it
|
|
const credentialAccessService = new CredentialAccessService();
|
|
await credentialAccessService.ensureAccessible({
|
|
credentialId: conferenceCredentialId,
|
|
loggedInUserId: user.id,
|
|
bookingOwnerId,
|
|
});
|
|
|
|
// Now fetch the credential with the key
|
|
conferenceCredential = await CredentialRepository.findFirstByIdWithKeyAndUser({
|
|
id: conferenceCredentialId,
|
|
});
|
|
}
|
|
return [...(credentials ? credentials : []), ...(conferenceCredential ? [conferenceCredential] : [])];
|
|
}
|
|
|
|
async function getLocationInEvtFormatOrThrow({
|
|
location,
|
|
organizer,
|
|
loggedInUserTranslate,
|
|
}: {
|
|
location: string;
|
|
organizer: {
|
|
name: string | null;
|
|
metadata: UserMetadata;
|
|
};
|
|
loggedInUserTranslate: Awaited<ReturnType<typeof getTranslation>>;
|
|
}) {
|
|
if (location !== OrganizerDefaultConferencingAppType) {
|
|
return location;
|
|
}
|
|
|
|
try {
|
|
return getLocationForOrganizerDefaultConferencingAppInEvtFormat({
|
|
organizer: {
|
|
name: organizer.name ?? "Organizer",
|
|
metadata: organizer.metadata,
|
|
},
|
|
loggedInUserTranslate,
|
|
});
|
|
} catch (e) {
|
|
if (e instanceof UserError) {
|
|
throw new TRPCError({ code: "BAD_REQUEST", message: e.message });
|
|
}
|
|
logger.error(safeStringify(e));
|
|
throw e;
|
|
}
|
|
}
|
|
// #endregion
|
|
|
|
/**
|
|
* An error that should be shown to the user
|
|
*/
|
|
export class UserError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = "LocationError";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* An error that should not be shown to the user
|
|
*/
|
|
export class SystemError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = "SystemError";
|
|
}
|
|
}
|
|
|
|
export function getLocationForOrganizerDefaultConferencingAppInEvtFormat({
|
|
organizer,
|
|
loggedInUserTranslate: translate,
|
|
}: {
|
|
organizer: {
|
|
name: string;
|
|
metadata: {
|
|
defaultConferencingApp?: NonNullable<UserMetadata>["defaultConferencingApp"];
|
|
} | null;
|
|
};
|
|
/**
|
|
* translate is used to translate if any error is thrown
|
|
*/
|
|
loggedInUserTranslate: Awaited<ReturnType<typeof getTranslation>>;
|
|
}) {
|
|
const organizerMetadata = organizer.metadata;
|
|
const defaultConferencingApp = organizerMetadata?.defaultConferencingApp;
|
|
if (!defaultConferencingApp) {
|
|
throw new UserError(
|
|
translate("organizer_default_conferencing_app_not_found", { organizer: organizer.name })
|
|
);
|
|
}
|
|
const defaultConferencingAppSlug = defaultConferencingApp.appSlug;
|
|
const app = getAppFromSlug(defaultConferencingAppSlug);
|
|
if (!app) {
|
|
throw new SystemError(`Default conferencing app ${defaultConferencingAppSlug} not found`);
|
|
}
|
|
const defaultConferencingAppLocationType = app.appData?.location?.type;
|
|
if (!defaultConferencingAppLocationType) {
|
|
throw new SystemError("Default conferencing app has no location type");
|
|
}
|
|
|
|
const location = defaultConferencingAppLocationType;
|
|
const locationType = getEventLocationType(location);
|
|
if (!locationType) {
|
|
throw new SystemError(`Location type not found: ${location}`);
|
|
}
|
|
|
|
if (locationType.linkType === "dynamic") {
|
|
// Dynamic location type need to return the location as it is e.g. integrations:zoom_video
|
|
return location;
|
|
}
|
|
|
|
const appLink = defaultConferencingApp.appLink;
|
|
if (!appLink) {
|
|
throw new SystemError(`Default conferencing app ${defaultConferencingAppSlug} has no app link`);
|
|
}
|
|
return appLink;
|
|
}
|
|
|
|
export async function editLocationHandler({ ctx, input, actionSource }: EditLocationOptions) {
|
|
const { newLocation, credentialId: conferenceCredentialId } = input;
|
|
const { booking, user: loggedInUser } = ctx;
|
|
|
|
const oldLocation = booking.location;
|
|
|
|
const organizer = await new UserRepository(prisma).findByIdOrThrow({ id: booking.userId || 0 });
|
|
const organizationId = booking.user?.profiles?.[0]?.organizationId ?? null;
|
|
|
|
const newLocationInEvtFormat = await getLocationInEvtFormatOrThrow({
|
|
location: newLocation,
|
|
organizer,
|
|
loggedInUserTranslate: await getTranslation(loggedInUser.locale ?? "en", "common"),
|
|
});
|
|
|
|
const evt = await buildCalEventFromBooking({
|
|
booking,
|
|
organizer,
|
|
location: newLocationInEvtFormat,
|
|
conferenceCredentialId,
|
|
organizationId,
|
|
});
|
|
|
|
const eventManager = new EventManager({
|
|
...ctx.user,
|
|
credentials: await getAllCredentialsIncludeServiceAccountKey({
|
|
user: ctx.user,
|
|
conferenceCredentialId,
|
|
bookingOwnerId: booking.userId,
|
|
}),
|
|
});
|
|
|
|
const updatedResult = await updateLocationInConnectedAppForBooking({
|
|
booking,
|
|
eventManager,
|
|
evt,
|
|
});
|
|
|
|
const additionalInformation = extractAdditionalInformation(updatedResult.results[0]);
|
|
|
|
const { updatedLocation } = await updateBookingLocationInDb({
|
|
booking,
|
|
evt: { ...evt, additionalInformation },
|
|
references: updatedResult.referencesToCreate,
|
|
});
|
|
|
|
try {
|
|
await sendLocationChangeEmailsAndSMS(
|
|
{ ...evt, additionalInformation },
|
|
booking?.eventType?.metadata as EventTypeMetadata
|
|
);
|
|
} catch (error) {
|
|
logger.error("Error sending LocationChangeEmails", safeStringify(error));
|
|
}
|
|
|
|
const bookingEventHandlerService = getBookingEventHandlerService();
|
|
const featuresRepository = getFeaturesRepository();
|
|
const isBookingAuditEnabled = organizationId
|
|
? await featuresRepository.checkIfTeamHasFeature(organizationId, "booking-audit")
|
|
: false;
|
|
|
|
await bookingEventHandlerService.onLocationChanged({
|
|
bookingUid: booking.uid,
|
|
actor: makeUserActor(loggedInUser.uuid),
|
|
organizationId,
|
|
source: actionSource,
|
|
auditData: {
|
|
location: {
|
|
old: oldLocation,
|
|
new: updatedLocation,
|
|
},
|
|
},
|
|
isBookingAuditEnabled,
|
|
});
|
|
|
|
return { message: "Location updated" };
|
|
}
|