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: hariom@cal.com <hariombalhara@gmail.com> * 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: hariom@cal.com <hariombalhara@gmail.com> * 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: hariom@cal.com <hariombalhara@gmail.com> * 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: hariom@cal.com <hariombalhara@gmail.com> * fix: add featuresRepository dependency to RecurringBookingService DI module Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * 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: hariom@cal.com <hariombalhara@gmail.com> * refactor: remove isBookingAuditEnabled from non-main flow methods Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * 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: hariom@cal.com <hariombalhara@gmail.com> * test: update roundRobinReassignment tests to include isBookingAuditEnabled Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * test: update roundRobinManualReassignment tests to include isBookingAuditEnabled Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * fix: inject featuresRepository in API V2 RecurringBookingService Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * fix: import PrismaWorkerModule for PrismaFeaturesRepository dependency Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * 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: hariom@cal.com <hariombalhara@gmail.com> * fix: use booking's organizationId for feature flag check in addGuests handler Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * fix: revert to user.organizationId for feature flag check in addGuests handler Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * fix: add isBookingAuditEnabled to onNoShowUpdated calls after main merge Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * fix: add isBookingAuditEnabled to onNoShowUpdated calls and update tests Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
322 lines
11 KiB
TypeScript
322 lines
11 KiB
TypeScript
import dayjs from "@calcom/dayjs";
|
|
import type { NoShowUpdatedAuditData } from "@calcom/features/booking-audit/lib/actions/NoShowUpdatedAuditActionService";
|
|
import { makeSystemActor } from "@calcom/features/booking-audit/lib/makeActor";
|
|
import { getBookingEventHandlerService } from "@calcom/features/bookings/di/BookingEventHandlerService.container";
|
|
import { getFeaturesRepository } from "@calcom/features/di/containers/FeaturesRepository";
|
|
import type { Host } from "@calcom/features/bookings/lib/getHostsAndGuests";
|
|
import { getHostsAndGuests } from "@calcom/features/bookings/lib/getHostsAndGuests";
|
|
import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload";
|
|
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
|
|
import logger from "@calcom/lib/logger";
|
|
import { safeStringify } from "@calcom/lib/safeStringify";
|
|
import prisma from "@calcom/prisma";
|
|
import type { TimeUnit } from "@calcom/prisma/enums";
|
|
import { BookingStatus, WebhookTriggerEvents } from "@calcom/prisma/enums";
|
|
import { getBooking } from "./getBooking";
|
|
import { getMeetingSessionsFromRoomName } from "./getMeetingSessionsFromRoomName";
|
|
import type { TTriggerNoShowPayloadSchema, TWebhook } from "./schema";
|
|
import { ZSendNoShowWebhookPayloadSchema } from "./schema";
|
|
|
|
type OriginalRescheduledBooking =
|
|
| {
|
|
rescheduledBy?: string | null;
|
|
}
|
|
| null
|
|
| undefined;
|
|
|
|
export type Booking = Awaited<ReturnType<typeof getBooking>>;
|
|
type Webhook = TWebhook;
|
|
export type Participants = TTriggerNoShowPayloadSchema["data"][number]["participants"];
|
|
type ParticipantsWithEmail = (Participants[number] & { email?: string; isLoggedIn?: boolean })[];
|
|
|
|
export function sendWebhookPayload(
|
|
webhook: Webhook,
|
|
triggerEvent: WebhookTriggerEvents,
|
|
booking: Booking & { guests?: Booking["attendees"] },
|
|
maxStartTime: number,
|
|
participants: ParticipantsWithEmail,
|
|
originalRescheduledBooking?: OriginalRescheduledBooking,
|
|
hostEmail?: string
|
|
): Promise<{ ok: boolean; status: number } | void> {
|
|
const maxStartTimeHumanReadable = dayjs.unix(maxStartTime).format("YYYY-MM-DD HH:mm:ss Z");
|
|
|
|
return sendGenericWebhookPayload({
|
|
secretKey: webhook.secret,
|
|
triggerEvent,
|
|
createdAt: new Date().toISOString(),
|
|
webhook: {
|
|
subscriberUrl: webhook.subscriberUrl,
|
|
appId: webhook.appId,
|
|
payloadTemplate: webhook.payloadTemplate,
|
|
version: webhook.version,
|
|
},
|
|
data: {
|
|
title: booking.title,
|
|
bookingId: booking.id,
|
|
bookingUid: booking.uid,
|
|
startTime: booking.startTime,
|
|
attendees: booking.attendees,
|
|
endTime: booking.endTime,
|
|
participants,
|
|
...(hostEmail ? { hostEmail } : {}),
|
|
...(triggerEvent === WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW
|
|
? { noShowHost: booking.noShowHost }
|
|
: {}),
|
|
...(triggerEvent === WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW && booking.guests
|
|
? { guests: booking.guests }
|
|
: {}),
|
|
...(originalRescheduledBooking ? { rescheduledBy: originalRescheduledBooking.rescheduledBy } : {}),
|
|
eventType: {
|
|
...booking.eventType,
|
|
id: booking.eventTypeId,
|
|
hosts: undefined,
|
|
users: undefined,
|
|
},
|
|
webhook: {
|
|
...webhook,
|
|
secret: undefined,
|
|
},
|
|
message:
|
|
triggerEvent === WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW
|
|
? `Guest didn't join the call or didn't join before ${maxStartTimeHumanReadable}`
|
|
: `Host with email ${hostEmail} didn't join the call or didn't join before ${maxStartTimeHumanReadable}`,
|
|
},
|
|
}).catch((e) => {
|
|
console.error(
|
|
`Error executing webhook for event: ${triggerEvent}, URL: ${webhook.subscriberUrl}`,
|
|
webhook,
|
|
e
|
|
);
|
|
});
|
|
}
|
|
|
|
export function calculateMaxStartTime(startTime: Date, time: number, timeUnit: TimeUnit): number {
|
|
return dayjs(startTime)
|
|
.add(time, timeUnit.toLowerCase() as dayjs.ManipulateType)
|
|
.unix();
|
|
}
|
|
|
|
function checkIfHostJoinedTheCall(email: string, allParticipants: ParticipantsWithEmail): boolean {
|
|
return allParticipants.some(
|
|
(participant) => participant.email && participant.isLoggedIn && participant.email === email
|
|
);
|
|
}
|
|
|
|
function checkIfGuestJoinedTheCall(email: string, allParticipants: ParticipantsWithEmail): boolean {
|
|
return allParticipants.some((participant) => participant.email && participant.email === email);
|
|
}
|
|
|
|
const getUserOrGuestById = async (id: string) => {
|
|
// Try User table (numeric IDs)
|
|
if (!isNaN(Number(id))) {
|
|
const user = await prisma.user.findUnique({
|
|
where: { id: parseInt(id) },
|
|
select: { email: true },
|
|
});
|
|
if (user) return { email: user.email, isLoggedIn: true };
|
|
}
|
|
|
|
// Try VideoCallGuest table (UUID)
|
|
const guestSession = await prisma.videoCallGuest
|
|
.findUnique({
|
|
where: { id },
|
|
select: { email: true },
|
|
})
|
|
.catch(() => null);
|
|
|
|
return { email: guestSession?.email, isLoggedIn: false };
|
|
};
|
|
|
|
export async function getParticipantsWithEmail(
|
|
allParticipants: Participants
|
|
): Promise<ParticipantsWithEmail> {
|
|
const participantsWithEmail = await Promise.all(
|
|
allParticipants.map(async (participant) => {
|
|
if (!participant.user_id) return participant;
|
|
|
|
const { email, isLoggedIn } = await getUserOrGuestById(participant.user_id);
|
|
return { ...participant, email, isLoggedIn };
|
|
})
|
|
);
|
|
|
|
return participantsWithEmail;
|
|
}
|
|
|
|
export const log = logger.getSubLogger({ prefix: ["triggerNoShowTask"] });
|
|
|
|
export const fireNoShowUpdatedEvent = async ({
|
|
booking,
|
|
noShowHostAudit,
|
|
attendeesNoShowAudit,
|
|
}: {
|
|
booking: {
|
|
id: number;
|
|
uid: string;
|
|
user?: { id: number; uuid: string } | null;
|
|
eventType?: { teamId?: number | null; parent?: { teamId?: number | null } | null } | null;
|
|
};
|
|
noShowHostAudit?: { old: boolean | null; new: boolean | null };
|
|
attendeesNoShowAudit?: NoShowUpdatedAuditData["attendeesNoShow"];
|
|
}): Promise<void> => {
|
|
const hasHostNoShow = noShowHostAudit && noShowHostAudit.new !== null;
|
|
const hasAttendeesNoShow = attendeesNoShowAudit && attendeesNoShowAudit.length > 0;
|
|
|
|
if (!hasHostNoShow && !hasAttendeesNoShow) {
|
|
return;
|
|
}
|
|
|
|
const hostUserUuid = booking.user?.uuid;
|
|
if (hasHostNoShow && !hostUserUuid) {
|
|
log.warn("Host no-show audit skipped: booking.user.uuid is undefined", { bookingUid: booking.uid });
|
|
}
|
|
|
|
try {
|
|
const orgId = await getOrgIdFromMemberOrTeamId({
|
|
memberId: booking.user?.id,
|
|
teamId: booking.eventType?.teamId || booking.eventType?.parent?.teamId,
|
|
});
|
|
|
|
const bookingEventHandlerService = getBookingEventHandlerService();
|
|
const featuresRepository = getFeaturesRepository();
|
|
const isBookingAuditEnabled = orgId
|
|
? await featuresRepository.checkIfTeamHasFeature(orgId, "booking-audit")
|
|
: false;
|
|
|
|
await bookingEventHandlerService.onNoShowUpdated({
|
|
bookingUid: booking.uid,
|
|
// This action is taken by the scheduled tasker job, so we use the system actor
|
|
actor: makeSystemActor(),
|
|
organizationId: orgId ?? null,
|
|
source: "SYSTEM",
|
|
auditData: {
|
|
...(hasHostNoShow && noShowHostAudit.new !== null && hostUserUuid
|
|
? {
|
|
host: {
|
|
userUuid: hostUserUuid,
|
|
noShow: { old: noShowHostAudit.old, new: noShowHostAudit.new },
|
|
},
|
|
}
|
|
: {}),
|
|
...(hasAttendeesNoShow ? { attendeesNoShow: attendeesNoShowAudit } : {}),
|
|
},
|
|
isBookingAuditEnabled,
|
|
});
|
|
} catch (error) {
|
|
log.error("Error logging audit for automatic no-show", error);
|
|
}
|
|
};
|
|
|
|
export const prepareNoShowTrigger = async (
|
|
payload: string
|
|
): Promise<{
|
|
booking: Booking;
|
|
webhook: TWebhook;
|
|
hosts: Host[];
|
|
hostsThatDidntJoinTheCall: Host[];
|
|
hostsThatJoinedTheCall: Host[];
|
|
numberOfHostsThatJoined: number;
|
|
didGuestJoinTheCall: boolean;
|
|
guestsThatJoinedTheCall: { email: string; name: string }[];
|
|
guestsThatDidntJoinTheCall: { email: string; name: string }[];
|
|
originalRescheduledBooking?: OriginalRescheduledBooking;
|
|
participants: ParticipantsWithEmail;
|
|
} | void> => {
|
|
const { bookingId, webhook } = ZSendNoShowWebhookPayloadSchema.parse(JSON.parse(payload));
|
|
|
|
const booking = await getBooking(bookingId);
|
|
let originalRescheduledBooking = null;
|
|
|
|
if (booking.fromReschedule) {
|
|
originalRescheduledBooking = await prisma.booking.findFirst({
|
|
where: {
|
|
uid: booking.fromReschedule,
|
|
status: {
|
|
in: [BookingStatus.ACCEPTED, BookingStatus.CANCELLED, BookingStatus.PENDING],
|
|
},
|
|
},
|
|
select: {
|
|
rescheduledBy: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
if (booking.status !== BookingStatus.ACCEPTED) {
|
|
log.debug(
|
|
"Booking is not accepted",
|
|
safeStringify({
|
|
bookingId,
|
|
webhook: { id: webhook.id },
|
|
})
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
const dailyVideoReference =
|
|
booking.references?.filter((reference) => reference.type === "daily_video")?.pop() ?? null;
|
|
|
|
if (!dailyVideoReference) {
|
|
log.error(
|
|
"Daily video reference not found",
|
|
safeStringify({
|
|
bookingId,
|
|
webhook: { id: webhook.id },
|
|
})
|
|
);
|
|
throw new Error(`Daily video reference not found in triggerHostNoShow with bookingId ${bookingId}`);
|
|
}
|
|
const meetingDetails = await getMeetingSessionsFromRoomName(dailyVideoReference.uid);
|
|
|
|
const { hosts, guests } = getHostsAndGuests(booking);
|
|
const allParticipants = meetingDetails.data.flatMap((meeting) => meeting.participants);
|
|
|
|
const participantsWithEmail = await getParticipantsWithEmail(allParticipants);
|
|
const hostsThatJoinedTheCall: Host[] = [];
|
|
const hostsThatDidntJoinTheCall: Host[] = [];
|
|
|
|
for (const host of hosts) {
|
|
if (checkIfHostJoinedTheCall(host.email, participantsWithEmail)) {
|
|
hostsThatJoinedTheCall.push(host);
|
|
} else {
|
|
hostsThatDidntJoinTheCall.push(host);
|
|
}
|
|
}
|
|
|
|
const numberOfHostsThatJoined = hosts.length - hostsThatDidntJoinTheCall.length;
|
|
|
|
const requireEmailForGuests = booking.eventType?.calVideoSettings?.requireEmailForGuests ?? false;
|
|
|
|
let didGuestJoinTheCall: boolean;
|
|
const guestsThatJoinedTheCall: { email: string; name: string }[] = [];
|
|
const guestsThatDidntJoinTheCall: { email: string; name: string }[] = [];
|
|
|
|
if (requireEmailForGuests) {
|
|
for (const guest of guests) {
|
|
if (checkIfGuestJoinedTheCall(guest.email, participantsWithEmail)) {
|
|
guestsThatJoinedTheCall.push(guest);
|
|
} else {
|
|
guestsThatDidntJoinTheCall.push(guest);
|
|
}
|
|
}
|
|
didGuestJoinTheCall = guestsThatJoinedTheCall.length > 0;
|
|
} else {
|
|
didGuestJoinTheCall = meetingDetails.data.some(
|
|
(meeting) => meeting.max_participants > numberOfHostsThatJoined
|
|
);
|
|
}
|
|
|
|
return {
|
|
hosts,
|
|
hostsThatDidntJoinTheCall,
|
|
hostsThatJoinedTheCall,
|
|
booking,
|
|
numberOfHostsThatJoined,
|
|
webhook,
|
|
didGuestJoinTheCall,
|
|
guestsThatJoinedTheCall,
|
|
guestsThatDidntJoinTheCall,
|
|
originalRescheduledBooking,
|
|
participants: participantsWithEmail,
|
|
};
|
|
};
|