* feat(bookings): add booking audit logging to instant bookings
wire up BookingEventHandlerService.onBookingCreated in
InstantBookingCreateService to emit audit events, matching the
pattern already used in RegularBookingService and
RecurringBookingService.
* refactor: extract fireBookingEvents and reuse existing orgId
* refactor: derive orgId once and pass to both webhook trigger and audit event
* fix: add missing return in webhook map callback
* refactor: make creationSource required for instant bookings
Both callers (WEBAPP and API_V2) always set creationSource, so validate
it upfront and use CreationSource enum type instead of string | null.
* fix: use ErrorWithCode instead of Error, pass userUuid to audit events
* fix: pass null for hostUserUuid in instant booking audit data
Instant bookings have status AWAITING_HOST with no assigned host,
so the booker's UUID should not be recorded as hostUserUuid.
* fix: address devin review - hostUserUuid and creationSource validation
* fix: address review - bookingMeta, getOrgIdFromMemberOrTeamId, required creationSource
- pass userUuid via bookingMeta instead of separate param (matches RegularBookingService pattern)
- restore getOrgIdFromMemberOrTeamId for proper org resolution instead of eventType.team.parentId
- make creationSource required with runtime validation instead of defaulting to WEBAPP
* fix: enforce creationSource at compile time instead of runtime
use Required<Pick<>> to make creationSource required in the type
signature. removes the runtime check since TypeScript catches
missing creationSource at build time.
* fix: simplify type signature and derive hostUserUuid from booking relation
- Replace Required<Pick<CreateInstantBookingData, 'creationSource'>> with inline { creationSource: CreationSource }
- Include user relation in booking create query to derive hostUserUuid
- Pass newBooking.user?.uuid instead of hardcoding null for userUuid in audit data
Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>
* chore: trigger CI
* fix: add missing impersonatedByUserUuid to instant booking meta
The CreateBookingMeta type requires impersonatedByUserUuid. Set it to
null for non-impersonated instant bookings.
* fix: show 'awaiting host' in audit log for instant bookings
Use booking status AWAITING_HOST to display "Booked (awaiting host)"
instead of "Booked with Unknown" when no host has accepted yet.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: add booking audit for instant meeting accept via connect-and-join
Extract fireInstantBookingAcceptedAuditEvent to InstantBookingCreateService
and fire it right after the DB update, before side-effect notifications.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test: add audit logging tests for instant booking creation
* fix: simulate audit failure in resilience test (identified by cubic)
The test 'should not throw when booking audit event fails' was not
actually simulating an audit failure. Added vi.spyOn on
BookingEventHandlerService.prototype.onBookingCreated to reject with
an error, and assert the spy was called, proving the try/catch in
fireBookingEvents properly catches the error without breaking the
booking flow.
Co-Authored-By: bot_apk <apk@cognition.ai>
* test: add audit event tests for connectAndJoin and fix InstantBooking audit test
* test
---------
Co-authored-by: Hariom Balhara <1780212+hariombalhara@users.noreply.github.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: hariom@cal.com <hariombalhara@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: bot_apk <apk@cognition.ai>
328 lines
9.7 KiB
TypeScript
328 lines
9.7 KiB
TypeScript
import { sendScheduledEmailsAndSMS } from "@calcom/emails/email-manager";
|
|
import { getBookingEventHandlerService } from "@calcom/features/bookings/di/BookingEventHandlerService.container";
|
|
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
|
|
import { scheduleNoShowTriggers } from "@calcom/features/bookings/lib/handleNewBooking/scheduleNoShowTriggers";
|
|
import { getFeaturesRepository } from "@calcom/features/di/containers/FeaturesRepository";
|
|
import {
|
|
type EventTypeBrandingData,
|
|
getEventTypeService,
|
|
} from "@calcom/features/eventtypes/di/EventTypeService.container";
|
|
import { getTranslation } from "@calcom/i18n/server";
|
|
import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj";
|
|
import logger from "@calcom/lib/logger";
|
|
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
|
|
import { prisma } from "@calcom/prisma";
|
|
import { BookingStatus } from "@calcom/prisma/enums";
|
|
import { bookingMetadataSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
|
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
|
|
import type { CalendarEvent } from "@calcom/types/Calendar";
|
|
import { TRPCError } from "@trpc/server";
|
|
import type { TConnectAndJoinInputSchema } from "./connectAndJoin.schema";
|
|
|
|
type Options = {
|
|
ctx: {
|
|
user: NonNullable<TrpcSessionUser>;
|
|
};
|
|
input: TConnectAndJoinInputSchema;
|
|
};
|
|
|
|
export const Handler = async ({ ctx, input }: Options) => {
|
|
const { token } = input;
|
|
const { user } = ctx;
|
|
const isLoggedInUserPartOfOrg = !!user.organization.id;
|
|
|
|
if (!isLoggedInUserPartOfOrg) {
|
|
throw new TRPCError({ code: "UNAUTHORIZED", message: "Logged in user is not member of Organization" });
|
|
}
|
|
|
|
const tOrganizer = await getTranslation(user?.locale ?? "en", "common");
|
|
|
|
const userBrandingInfo = await prisma.user.findUnique({
|
|
where: { id: user.id },
|
|
select: {
|
|
hideBranding: true,
|
|
profiles: {
|
|
select: {
|
|
organization: { select: { hideBranding: true } },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const instantMeetingToken = await prisma.instantMeetingToken.findUnique({
|
|
select: {
|
|
expires: true,
|
|
teamId: true,
|
|
booking: {
|
|
select: {
|
|
id: true,
|
|
status: true,
|
|
user: {
|
|
select: {
|
|
id: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
where: {
|
|
token,
|
|
team: {
|
|
members: {
|
|
some: {
|
|
userId: user.id,
|
|
accepted: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
// Check if logged in user belong to current team
|
|
if (!instantMeetingToken) {
|
|
throw new TRPCError({ code: "BAD_REQUEST", message: "token_not_found" });
|
|
}
|
|
|
|
if (!instantMeetingToken.booking?.id) {
|
|
throw new TRPCError({ code: "FORBIDDEN", message: "token_invalid_expired" });
|
|
}
|
|
|
|
// Check if token has not expired
|
|
if (instantMeetingToken.expires < new Date()) {
|
|
throw new TRPCError({ code: "BAD_REQUEST", message: "token_invalid_expired" });
|
|
}
|
|
|
|
// Check if Booking is already accepted by any other user
|
|
let isBookingAlreadyAcceptedBySomeoneElse = false;
|
|
if (
|
|
instantMeetingToken.booking.status === BookingStatus.ACCEPTED &&
|
|
instantMeetingToken.booking?.user?.id !== user.id
|
|
) {
|
|
isBookingAlreadyAcceptedBySomeoneElse = true;
|
|
}
|
|
|
|
// Update User in Booking
|
|
const updatedBooking = await prisma.booking.update({
|
|
where: {
|
|
id: instantMeetingToken.booking.id,
|
|
},
|
|
data: {
|
|
...(isBookingAlreadyAcceptedBySomeoneElse
|
|
? { status: BookingStatus.ACCEPTED }
|
|
: {
|
|
status: BookingStatus.ACCEPTED,
|
|
user: {
|
|
connect: {
|
|
id: user.id,
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
select: {
|
|
title: true,
|
|
description: true,
|
|
customInputs: true,
|
|
startTime: true,
|
|
references: true,
|
|
endTime: true,
|
|
attendees: true,
|
|
eventTypeId: true,
|
|
responses: true,
|
|
metadata: true,
|
|
eventType: {
|
|
select: {
|
|
id: true,
|
|
owner: true,
|
|
teamId: true,
|
|
title: true,
|
|
slug: true,
|
|
requiresConfirmation: true,
|
|
currency: true,
|
|
length: true,
|
|
description: true,
|
|
price: true,
|
|
bookingFields: true,
|
|
disableGuests: true,
|
|
metadata: true,
|
|
hideOrganizerEmail: true,
|
|
customInputs: true,
|
|
parentId: true,
|
|
customReplyToEmail: true,
|
|
team: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
hideBranding: true,
|
|
parent: { select: { hideBranding: true } },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
location: true,
|
|
userId: true,
|
|
id: true,
|
|
uid: true,
|
|
status: true,
|
|
},
|
|
});
|
|
|
|
await fireBookingAcceptedAuditEvent({
|
|
bookingUid: updatedBooking.uid,
|
|
actorUserUuid: user.uuid,
|
|
organizationId: user.organizationId,
|
|
oldStatus: instantMeetingToken.booking.status,
|
|
});
|
|
|
|
const locationVideoCallUrl = bookingMetadataSchema.parse(updatedBooking.metadata || {})?.videoCallUrl;
|
|
|
|
if (!locationVideoCallUrl) {
|
|
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "meeting_url_not_found" });
|
|
}
|
|
|
|
const videoCallReference = updatedBooking.references.find((reference) => reference.type.includes("_video"));
|
|
const videoCallData = {
|
|
type: videoCallReference?.type,
|
|
id: videoCallReference?.meetingId,
|
|
password: videoCallReference?.meetingPassword,
|
|
url: videoCallReference?.meetingUrl,
|
|
};
|
|
|
|
const { eventType } = updatedBooking;
|
|
|
|
// Send Scheduled Email to Organizer and Attendees
|
|
|
|
const translations = new Map();
|
|
const attendeesListPromises = updatedBooking.attendees.map(async (attendee) => {
|
|
const locale = attendee.locale ?? "en";
|
|
let translate = translations.get(locale);
|
|
if (!translate) {
|
|
translate = await getTranslation(locale, "common");
|
|
translations.set(locale, translate);
|
|
}
|
|
return {
|
|
name: attendee.name,
|
|
email: attendee.email,
|
|
timeZone: attendee.timeZone,
|
|
language: {
|
|
translate,
|
|
locale,
|
|
},
|
|
};
|
|
});
|
|
|
|
const attendeesList = await Promise.all(attendeesListPromises);
|
|
|
|
const evt: CalendarEvent = {
|
|
type: updatedBooking?.eventType?.slug as string,
|
|
title: updatedBooking.title,
|
|
description: updatedBooking.description,
|
|
...getCalEventResponses({
|
|
bookingFields: eventType?.bookingFields ?? null,
|
|
booking: updatedBooking,
|
|
}),
|
|
customInputs: isPrismaObjOrUndefined(updatedBooking.customInputs),
|
|
startTime: updatedBooking.startTime.toISOString(),
|
|
endTime: updatedBooking.endTime.toISOString(),
|
|
organizer: {
|
|
email: user.email,
|
|
name: user.name || "Unnamed",
|
|
username: user.username || undefined,
|
|
timeZone: user.timeZone,
|
|
timeFormat: getTimeFormatStringFromUserTimeFormat(user.timeFormat),
|
|
language: { translate: tOrganizer, locale: user.locale ?? "en" },
|
|
},
|
|
hideOrganizerEmail: updatedBooking.eventType?.hideOrganizerEmail,
|
|
attendees: attendeesList,
|
|
location: updatedBooking.location ?? "",
|
|
uid: updatedBooking.uid,
|
|
requiresConfirmation: false,
|
|
eventTypeId: eventType?.id,
|
|
videoCallData,
|
|
customReplyToEmail: eventType?.customReplyToEmail,
|
|
organizationId: user?.organizationId ?? null,
|
|
team: updatedBooking.eventType?.team
|
|
? {
|
|
name: updatedBooking.eventType.team.name,
|
|
id: updatedBooking.eventType.team.id,
|
|
members: [],
|
|
}
|
|
: undefined,
|
|
hideBranding: updatedBooking.eventTypeId
|
|
? await getEventTypeService().shouldHideBrandingForEventType(updatedBooking.eventTypeId, {
|
|
team: updatedBooking.eventType?.team
|
|
? {
|
|
hideBranding: updatedBooking.eventType.team.hideBranding,
|
|
parent: updatedBooking.eventType.team.parent,
|
|
}
|
|
: null,
|
|
owner: {
|
|
id: user.id,
|
|
hideBranding: userBrandingInfo?.hideBranding ?? null,
|
|
profiles: userBrandingInfo?.profiles ?? [],
|
|
},
|
|
} satisfies EventTypeBrandingData)
|
|
: false,
|
|
};
|
|
|
|
const eventTypeMetadata = EventTypeMetaDataSchema.parse(updatedBooking?.eventType?.metadata);
|
|
|
|
await sendScheduledEmailsAndSMS(
|
|
{
|
|
...evt,
|
|
},
|
|
undefined,
|
|
false,
|
|
false,
|
|
eventTypeMetadata
|
|
);
|
|
|
|
await scheduleNoShowTriggers({
|
|
booking: {
|
|
startTime: updatedBooking.startTime,
|
|
id: updatedBooking.id,
|
|
location: updatedBooking.location,
|
|
uid: updatedBooking.uid,
|
|
},
|
|
triggerForUser: !eventType?.teamId || (eventType?.teamId && eventType?.parentId),
|
|
organizerUser: { id: user.id },
|
|
eventTypeId: eventType?.id ?? null,
|
|
teamId: eventType?.teamId,
|
|
orgId: user.organizationId,
|
|
});
|
|
|
|
return { isBookingAlreadyAcceptedBySomeoneElse, meetingUrl: locationVideoCallUrl };
|
|
};
|
|
|
|
async function fireBookingAcceptedAuditEvent({
|
|
bookingUid,
|
|
actorUserUuid,
|
|
organizationId,
|
|
oldStatus,
|
|
}: {
|
|
bookingUid: string;
|
|
actorUserUuid: string;
|
|
organizationId: number | null;
|
|
oldStatus: BookingStatus;
|
|
}) {
|
|
try {
|
|
const featuresRepository = getFeaturesRepository();
|
|
const isBookingAuditEnabled = organizationId
|
|
? await featuresRepository.checkIfTeamHasFeature(organizationId, "booking-audit")
|
|
: false;
|
|
|
|
const bookingEventHandlerService = getBookingEventHandlerService();
|
|
await bookingEventHandlerService.onBookingAccepted({
|
|
bookingUid,
|
|
actor: { identifiedBy: "user", userUuid: actorUserUuid },
|
|
organizationId,
|
|
auditData: {
|
|
status: { old: oldStatus, new: BookingStatus.ACCEPTED },
|
|
},
|
|
source: "WEBAPP",
|
|
isBookingAuditEnabled,
|
|
});
|
|
} catch (error) {
|
|
logger.error("Error firing booking accepted audit for instant meeting", error);
|
|
}
|
|
}
|