diff --git a/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts b/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts index 8ee9cff1347f76..05818d0acfde51 100644 --- a/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts +++ b/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts @@ -275,6 +275,7 @@ export class BookingsController_2024_04_15 { platformCancelUrl: bookingRequest.platformCancelUrl, platformRescheduleUrl: bookingRequest.platformRescheduleUrl, platformBookingUrl: bookingRequest.platformBookingUrl, + actionSource: "API_V2" as const, }); if (!res.onlyRemovedAttendee) { void (await this.billingService.cancelUsageByBookingUid(res.bookingUid)); @@ -311,6 +312,7 @@ export class BookingsController_2024_04_15 { noShowHost: body.noShowHost, userId: user.id, userUuid: user.uuid, + actionSource: "API_V2" as const, }); return { status: SUCCESS_STATUS, data: markNoShowResponse }; diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/booking-guests.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/booking-guests.service.ts index a89773441bcd81..a6d8b5173dcc05 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/services/booking-guests.service.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/booking-guests.service.ts @@ -15,7 +15,7 @@ export class BookingGuestsService_2024_08_13 { private readonly bookingsRepository: BookingsRepository_2024_08_13, private readonly bookingsService: BookingsService_2024_08_13, private readonly platformBookingsService: PlatformBookingsService - ) {} + ) { } async addGuests(bookingUid: string, input: AddGuestsInput_2024_08_13, user: ApiAuthGuardUser) { const booking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(bookingUid); @@ -33,6 +33,7 @@ export class BookingGuestsService_2024_08_13 { ctx: { user }, input: { bookingId: booking.id, guests: input.guests }, emailsEnabled, + actionSource: "API_V2", }); if (res.message === "Guests added") { return await this.bookingsService.getBooking(bookingUid, user); diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts index 6e6b73b9dd2429..2dcb59ed384ae9 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts @@ -114,8 +114,9 @@ export class BookingsService_2024_08_13 { private readonly regularBookingService: RegularBookingService, private readonly recurringBookingService: RecurringBookingService, private readonly instantBookingCreateService: InstantBookingCreateService, - private readonly eventTypeAccessService: EventTypeAccessService - ) {} + private readonly eventTypeAccessService: EventTypeAccessService, + private readonly auditActorRepository: PrismaAuditActorRepository + ) { } async createBooking(request: Request, body: CreateBookingInput, authUser: AuthOptionalUser) { let bookingTeamEventType = false; @@ -321,8 +322,7 @@ export class BookingsService_2024_08_13 { const allowedOptionValues = eventTypeBookingField.options.map((opt) => opt.value); if (!this.isValidSingleOptionValue(submittedValue, allowedOptionValues)) { throw new BadRequestException( - `Invalid option '${submittedValue}' for booking field '${ - eventTypeBookingField.name + `Invalid option '${submittedValue}' for booking field '${eventTypeBookingField.name }'. Allowed options are: ${allowedOptionValues.join(", ")}.` ); } @@ -336,8 +336,7 @@ export class BookingsService_2024_08_13 { const allowedOptionValues = eventTypeBookingField.options.map((opt) => opt.value); if (!this.areValidMultipleOptionValues(submittedValues, allowedOptionValues)) { throw new BadRequestException( - `One or more invalid options for booking field '${ - eventTypeBookingField.name + `One or more invalid options for booking field '${eventTypeBookingField.name }'. Allowed options are: ${allowedOptionValues.join(", ")}.` ); } @@ -351,8 +350,7 @@ export class BookingsService_2024_08_13 { const allowedOptionValues = eventTypeBookingField.options.map((opt) => opt.value); if (!this.areValidMultipleOptionValues(submittedValues, allowedOptionValues)) { throw new BadRequestException( - `One or more invalid options for booking field '${ - eventTypeBookingField.name + `One or more invalid options for booking field '${eventTypeBookingField.name }'. Allowed options are: ${allowedOptionValues.join(", ")}.` ); } @@ -367,8 +365,7 @@ export class BookingsService_2024_08_13 { const allowedOptionValues = eventTypeBookingField.options.map((opt) => opt.value); if (!this.isValidSingleOptionValue(submittedValue, allowedOptionValues)) { throw new BadRequestException( - `Invalid option '${submittedValue}' for booking field '${ - eventTypeBookingField.name + `Invalid option '${submittedValue}' for booking field '${eventTypeBookingField.name }'. Allowed options are: ${allowedOptionValues.join(", ")}.` ); } @@ -921,40 +918,41 @@ export class BookingsService_2024_08_13 { return await this.getBooking(recurringBookingUid, authUser); } - async markAbsent( - bookingUid: string, - bookingOwnerId: number, - body: MarkAbsentBookingInput_2024_08_13, - userUuid?: string - ) { - const bodyTransformed = this.inputService.transformInputMarkAbsentBooking(body); - const bookingBefore = await this.bookingsRepository.getByUid(bookingUid); - - if (!bookingBefore) { - throw new NotFoundException(`Booking with uid=${bookingUid} not found.`); - } + async markAbsent( + bookingUid: string, + bookingOwnerId: number, + body: MarkAbsentBookingInput_2024_08_13, + userUuid?: string + ) { + const bodyTransformed = this.inputService.transformInputMarkAbsentBooking(body); + const bookingBefore = await this.bookingsRepository.getByUid(bookingUid); - const nowUtc = DateTime.utc(); - const bookingStartTimeUtc = DateTime.fromJSDate(bookingBefore.startTime, { zone: "utc" }); + if (!bookingBefore) { + throw new NotFoundException(`Booking with uid=${bookingUid} not found.`); + } - if (nowUtc < bookingStartTimeUtc) { - throw new BadRequestException( - `Bookings can only be marked as absent after their scheduled start time. Current time in UTC+0: ${nowUtc.toISO()}, Booking start time in UTC+0: ${bookingStartTimeUtc.toISO()}` - ); - } + const nowUtc = DateTime.utc(); + const bookingStartTimeUtc = DateTime.fromJSDate(bookingBefore.startTime, { zone: "utc" }); - const platformClientParams = bookingBefore?.eventTypeId - ? await this.platformBookingsService.getOAuthClientParams(bookingBefore.eventTypeId) - : undefined; + if (nowUtc < bookingStartTimeUtc) { + throw new BadRequestException( + `Bookings can only be marked as absent after their scheduled start time. Current time in UTC+0: ${nowUtc.toISO()}, Booking start time in UTC+0: ${bookingStartTimeUtc.toISO()}` + ); + } - await handleMarkNoShow({ - bookingUid, - attendees: bodyTransformed.attendees, - noShowHost: bodyTransformed.noShowHost, - userId: bookingOwnerId, - userUuid, - platformClientParams, - }); + const platformClientParams = bookingBefore?.eventTypeId + ? await this.platformBookingsService.getOAuthClientParams(bookingBefore.eventTypeId) + : undefined; + + await handleMarkNoShow({ + bookingUid, + attendees: bodyTransformed.attendees, + noShowHost: bodyTransformed.noShowHost, + userId: bookingOwnerId, + userUuid, + platformClientParams, + actionSource: "API_V2", + }); const booking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(bookingUid); @@ -1038,6 +1036,8 @@ export class BookingsService_2024_08_13 { emailsEnabled, platformClientParams, reassignedById: reassignedByUser.id, + reassignedByUuid: reassignedByUser.uuid, + actionSource: "API_V2", }); } catch (error) { if (error instanceof Error) { @@ -1103,8 +1103,10 @@ export class BookingsService_2024_08_13 { orgId: profile?.organizationId || null, reassignReason: body.reason, reassignedById: reassignedByUser.id, + reassignedByUuid: reassignedByUser.uuid, emailsEnabled, platformClientParams, + actionSource: "API_V2", }); return this.outputService.getOutputReassignedBooking(reassigned); diff --git a/apps/api/v2/src/lib/services/recurring-booking.service.ts b/apps/api/v2/src/lib/services/recurring-booking.service.ts index d40b53db4536ec..79888bdf6edee5 100644 --- a/apps/api/v2/src/lib/services/recurring-booking.service.ts +++ b/apps/api/v2/src/lib/services/recurring-booking.service.ts @@ -1,13 +1,18 @@ import { RegularBookingService } from "@/lib/services/regular-booking.service"; +import { BookingEventHandlerService } from "@/lib/services/booking-event-handler.service"; import { Injectable } from "@nestjs/common"; import { RecurringBookingService as BaseRecurringBookingService } from "@calcom/platform-libraries/bookings"; @Injectable() export class RecurringBookingService extends BaseRecurringBookingService { - constructor(regularBookingService: RegularBookingService) { + constructor( + regularBookingService: RegularBookingService, + bookingEventHandler: BookingEventHandlerService + ) { super({ regularBookingService, + bookingEventHandler, }); } } diff --git a/apps/web/pages/api/book/event.ts b/apps/web/pages/api/book/event.ts index 894fc8fb92c54d..46a7825ce5d32d 100644 --- a/apps/web/pages/api/book/event.ts +++ b/apps/web/pages/api/book/event.ts @@ -54,28 +54,12 @@ async function handler(req: NextApiRequest & { userId?: number; traceContext: Tr hostname: req.headers.host || "", forcedSlug: req.headers["x-cal-force-slug"] as string | undefined, traceContext: req.traceContext, + userUuid: session?.user?.uuid || undefined, impersonatedByUserUuid: session?.user?.impersonatedBy?.uuid, }, }); - // const booking = await createBookingThroughFactory(); return booking; - - // To be added in the follow-up PR - // async function createBookingThroughFactory() { - // console.log("Creating booking through factory"); - // const regularBookingService = getRegularBookingService(); - - // const booking = await regularBookingService.createBooking({ - // bookingData: req.body, - // bookingMeta: { - // userId: session?.user?.id || -1, - // hostname: req.headers.host || "", - // forcedSlug: req.headers["x-cal-force-slug"] as string | undefined, - // }, - // }); - // return booking; - // } } export default defaultResponder(handler, "/api/book/event"); diff --git a/apps/web/pages/api/book/recurring-event.ts b/apps/web/pages/api/book/recurring-event.ts index 0fdd9d3caece7e..f53562a064c19e 100644 --- a/apps/web/pages/api/book/recurring-event.ts +++ b/apps/web/pages/api/book/recurring-event.ts @@ -1,5 +1,5 @@ import type { NextApiRequest } from "next"; - +import { CreationSource } from "@calcom/prisma/enums"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import { getRecurringBookingService } from "@calcom/features/bookings/di/RecurringBookingService.container"; import type { BookingResponse } from "@calcom/features/bookings/types"; @@ -45,7 +45,10 @@ async function handler(req: NextApiRequest & RequestMeta) { const recurringBookingService = getRecurringBookingService(); const createdBookings: BookingResponse[] = await recurringBookingService.createBooking({ - bookingData: req.body, + bookingData: { + ...req.body, + creationSource: CreationSource.WEBAPP, + }, bookingMeta: { userId: session?.user?.id || -1, platformClientId: req.platformClientId, @@ -54,6 +57,7 @@ async function handler(req: NextApiRequest & RequestMeta) { platformRescheduleUrl: req.platformRescheduleUrl, platformBookingLocation: req.platformBookingLocation, noEmail: req.noEmail, + userUuid: session?.user?.uuid || undefined, }, }); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index d95352b64a3fb0..bcc421ff23f62f 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -3238,7 +3238,7 @@ "only_if_field_is_empty": "Only if field is empty", "booking_start_date": "Booking start date", "booking_created_date": "Booking created date", - "booking_reassigned_to_host": "Booking reassigned to {{host}}", + "booking_reassigned_to_host": "Reassigned to {{host}}", "no_contact_owner": "No contact owner", "routing_forms_created": "Routing Forms Created", "routing_forms_total_responses": "Total Responses", @@ -3642,6 +3642,8 @@ "pbac_action_read_team_bookings": "View Team Bookings", "pbac_action_read_org_bookings": "View Organization Bookings", "pbac_action_read_recordings": "View Recordings", + "pbac_action_read_team_audit_logs": "View team audit logs", + "pbac_action_read_org_audit_logs": "View organization audit logs", "pbac_action_impersonate": "Impersonate", "pbac_action_edit_users": "Edit Users", "role_created_successfully": "Role created successfully", @@ -3712,6 +3714,8 @@ "pbac_desc_view_team_bookings": "View team bookings", "pbac_desc_view_organization_bookings": "View organization bookings", "pbac_desc_view_booking_recordings": "View booking recordings", + "pbac_desc_view_team_audit_logs": "View audit logs for team bookings", + "pbac_desc_view_org_audit_logs": "View audit logs for organization bookings", "pbac_desc_update_bookings": "Update bookings", "pbac_desc_manage_bookings": "All actions on bookings", "pbac_desc_view_team_insights": "View team insights", @@ -4180,7 +4184,7 @@ "booking_history": "Booking History", "booking_history_description": "View the history of actions performed on this booking", "booking_audit_action": { - "created": "Created", + "created": "Booked with {{host}}", "cancelled": "Cancelled", "rescheduled": "Rescheduled {{oldDate}} -> <0>{{newDate}}", "rescheduled_from": "Rescheduled <0>{{oldDate}} -> {{newDate}}", @@ -4195,9 +4199,11 @@ "location_changed": "Location Changed", "location_changed_from_to": "Location changed from {{fromLocation}} to {{toLocation}}", "attendee_no_show_updated": "Attendee No-Show Updated", - "type": "Assignment Type", - "assignmentType_manual": "Manual Assignment", - "assignmentType_roundRobin": "Round Robin Assignment", + "seat_booked": "Seat Booked", + "seat_rescheduled": "Seat Rescheduled {{oldDate}} -> <0>{{newDate}}", + "assignment_type": "Assignment", + "assignmentType_manual": "Manual", + "assignmentType_roundRobin": "Round Robin", "actor_impersonated_by": "Impersonator", "source": "Source" }, diff --git a/packages/app-store/_utils/payments/handlePaymentSuccess.ts b/packages/app-store/_utils/payments/handlePaymentSuccess.ts index cc8f4d7eed3dff..4c078ee0ba2167 100644 --- a/packages/app-store/_utils/payments/handlePaymentSuccess.ts +++ b/packages/app-store/_utils/payments/handlePaymentSuccess.ts @@ -6,6 +6,8 @@ import { getAllCredentialsIncludeServiceAccountKey } from "@calcom/features/book import { handleBookingRequested } from "@calcom/features/bookings/lib/handleBookingRequested"; import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation"; import { getBooking } from "@calcom/features/bookings/lib/payment/getBooking"; +import type { Actor } from "@calcom/features/booking-audit/lib/dto/types"; +import { makeAppActor, makeAppActorUsingSlug } from "@calcom/features/booking-audit/lib/makeActor"; import { CreditService } from "@calcom/features/ee/billing/credit-service"; import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer"; import { getAllWorkflowsFromEventType } from "@calcom/features/ee/workflows/lib/getAllWorkflowsFromEventType"; @@ -28,9 +30,12 @@ import prisma from "@calcom/prisma"; import type { Prisma } from "@calcom/prisma/client"; import { BookingStatus, WebhookTriggerEvents, WorkflowTriggerEvents } from "@calcom/prisma/enums"; import type { EventTypeMetadata } from "@calcom/prisma/zod-utils"; +import { getAppNameFromSlug } from "@calcom/features/booking-audit/lib/getAppNameFromSlug"; const log = logger.getSubLogger({ prefix: ["[handlePaymentSuccess]"] }); -export async function handlePaymentSuccess(paymentId: number, bookingId: number, traceContext: TraceContext) { + +export async function handlePaymentSuccess(params: { paymentId: number; appSlug: string; bookingId: number; traceContext: TraceContext }) { + const { paymentId, bookingId, appSlug, traceContext } = params; const updatedTraceContext = distributedTracing.updateTrace(traceContext, { bookingId, paymentId, @@ -38,6 +43,24 @@ export async function handlePaymentSuccess(paymentId: number, bookingId: number, log.debug(`handling payment success for bookingId ${bookingId}`); const { booking, user: userWithCredentials, evt, eventType } = await getBooking(bookingId); + const apps = eventTypeAppMetadataOptionalSchema.parse(eventType?.metadata?.apps); + + let actor: Actor; + const appData = apps?.[appSlug as keyof typeof apps]; + if (appData?.credentialId) { + actor = makeAppActor({ credentialId: appData.credentialId }); + } else { + log.warn(`Missing credentialId for payment app, using appSlug fallback`, { + bookingId, + eventTypeId: eventType?.id, + appSlug, + }); + actor = makeAppActorUsingSlug({ + appSlug, + name: getAppNameFromSlug({ appSlug }), + }); + } + try { await tasker.cancelWithReference(booking.uid, "sendAwaitingPaymentEmail"); log.debug(`Cancelled scheduled awaiting payment email for booking ${bookingId}`); @@ -70,7 +93,6 @@ export async function handlePaymentSuccess(paymentId: number, bookingId: number, const areEmailsEnabled = platformOAuthClient?.areEmailsEnabled ?? true; if (isConfirmed) { - const apps = eventTypeAppMetadataOptionalSchema.parse(eventType?.metadata?.apps); const eventManager = new EventManager({ ...userWithCredentials, credentials: allCredentials }, apps); const scheduleResult = areCalendarEventsEnabled ? await eventManager.create(evt) @@ -237,6 +259,8 @@ export async function handlePaymentSuccess(paymentId: number, bookingId: number, booking, paid: true, platformClientParams, + source: "WEBHOOK", + actor, traceContext: updatedTraceContext, }); } else { diff --git a/packages/app-store/alby/api/webhook.ts b/packages/app-store/alby/api/webhook.ts index 0cbe50fcc63466..79933ddd07007f 100644 --- a/packages/app-store/alby/api/webhook.ts +++ b/packages/app-store/alby/api/webhook.ts @@ -92,7 +92,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const traceContext = distributedTracing.createTrace("alby_webhook", { meta: { paymentId: payment.id, bookingId: payment.bookingId }, }); - return await handlePaymentSuccess(payment.id, payment.bookingId, traceContext); + return await handlePaymentSuccess({ + paymentId: payment.id, + bookingId: payment.bookingId, + appSlug: "alby", + traceContext, + }); } catch (_err) { const err = getServerErrorFromUnknown(_err); console.error(`Webhook Error: ${err.message}`); diff --git a/packages/app-store/btcpayserver/api/webhook.ts b/packages/app-store/btcpayserver/api/webhook.ts index bd10b6b5a24a29..e8da1db5093cde 100644 --- a/packages/app-store/btcpayserver/api/webhook.ts +++ b/packages/app-store/btcpayserver/api/webhook.ts @@ -91,7 +91,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const traceContext = distributedTracing.createTrace("btcpayserver_webhook", { meta: { paymentId: payment.id, bookingId: payment.bookingId }, }); - await handlePaymentSuccess(payment.id, payment.bookingId, traceContext); + await handlePaymentSuccess({ + paymentId: payment.id, + bookingId: payment.bookingId, + appSlug: "btcpayserver", + traceContext, + }); return res.status(200).json({ success: true }); } catch (_err) { const err = getServerErrorFromUnknown(_err); diff --git a/packages/app-store/hitpay/api/webhook.ts b/packages/app-store/hitpay/api/webhook.ts index ed0d520dfce10f..a46a439c0ed9e4 100644 --- a/packages/app-store/hitpay/api/webhook.ts +++ b/packages/app-store/hitpay/api/webhook.ts @@ -113,7 +113,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const traceContext = distributedTracing.createTrace("hitpay_webhook", { meta: { paymentId: payment.id, bookingId: payment.bookingId }, }); - return await handlePaymentSuccess(payment.id, payment.bookingId, traceContext); + return await handlePaymentSuccess({ + paymentId: payment.id, + bookingId: payment.bookingId, + appSlug: "hitpay", + traceContext, + }); } catch (_err) { const err = getServerErrorFromUnknown(_err); console.error(`Webhook Error: ${err.message}`); diff --git a/packages/app-store/paypal/api/webhook.ts b/packages/app-store/paypal/api/webhook.ts index ec029a03da65ca..efa932566b1324 100644 --- a/packages/app-store/paypal/api/webhook.ts +++ b/packages/app-store/paypal/api/webhook.ts @@ -66,7 +66,12 @@ export async function handlePaypalPaymentSuccess( const traceContext = distributedTracing.createTrace("paypal_webhook", { meta: { paymentId: payment.id, bookingId: payment.bookingId }, }); - return await handlePaymentSuccess(payment.id, payment.bookingId, traceContext); + return await handlePaymentSuccess({ + paymentId: payment.id, + bookingId: payment.bookingId, + appSlug: "paypal", + traceContext, + }); } export default async function handler(req: NextApiRequest, res: NextApiResponse) { diff --git a/packages/features/booking-audit/client/components/BookingHistory.tsx b/packages/features/booking-audit/client/components/BookingHistory.tsx index 1ba2a0677ef679..2049ba0883d3ed 100644 --- a/packages/features/booking-audit/client/components/BookingHistory.tsx +++ b/packages/features/booking-audit/client/components/BookingHistory.tsx @@ -252,13 +252,13 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) { {log.actor.displayName} {actorRole && {` (${t(actorRole)})`}} - + {dayjs(log.timestamp).fromNow()} - - - - + + + +
@@ -327,11 +327,11 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) { )}
- - + + ); })} - + ); } diff --git a/packages/features/booking-audit/di/BookingAuditTaskConsumer.module.ts b/packages/features/booking-audit/di/BookingAuditTaskConsumer.module.ts index 0e81f0a1116257..be317653d08675 100644 --- a/packages/features/booking-audit/di/BookingAuditTaskConsumer.module.ts +++ b/packages/features/booking-audit/di/BookingAuditTaskConsumer.module.ts @@ -4,6 +4,7 @@ import { moduleLoader as bookingAuditRepositoryModuleLoader } from "@calcom/feat import { moduleLoader as auditActorRepositoryModuleLoader } from "@calcom/features/booking-audit/di/AuditActorRepository.module"; import { moduleLoader as featuresRepositoryModuleLoader } from "@calcom/features/di/modules/Features"; import { moduleLoader as userRepositoryModuleLoader } from "@calcom/features/di/modules/User"; +import { moduleLoader as bookingRepositoryModuleLoader } from "@calcom/features/di/modules/Booking"; import { createModule, bindModuleToClassOnToken } from "../../di/di"; @@ -21,6 +22,7 @@ const loadModule = bindModuleToClassOnToken({ auditActorRepository: auditActorRepositoryModuleLoader, featuresRepository: featuresRepositoryModuleLoader, userRepository: userRepositoryModuleLoader, + bookingRepository: bookingRepositoryModuleLoader, }, }); diff --git a/packages/features/booking-audit/lib/actions/AcceptedAuditActionService.ts b/packages/features/booking-audit/lib/actions/AcceptedAuditActionService.ts index f939272547eff8..b877d7abdd5ffe 100644 --- a/packages/features/booking-audit/lib/actions/AcceptedAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/AcceptedAuditActionService.ts @@ -66,7 +66,7 @@ export class AcceptedAuditActionService implements IAuditActionService { getDisplayJson({ storedData, }: GetDisplayJsonParams): AcceptedAuditDisplayData { - const { fields } = this.parseStored({ version: storedData.version, fields: storedData.fields }); + const { fields } = this.parseStored(storedData); return { previousStatus: fields.status.old ?? null, newStatus: fields.status.new ?? null, diff --git a/packages/features/booking-audit/lib/actions/AttendeeAddedAuditActionService.ts b/packages/features/booking-audit/lib/actions/AttendeeAddedAuditActionService.ts index 7df6c3cebc7235..d1578f6055d6e7 100644 --- a/packages/features/booking-audit/lib/actions/AttendeeAddedAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/AttendeeAddedAuditActionService.ts @@ -65,7 +65,7 @@ export class AttendeeAddedAuditActionService implements IAuditActionService { getDisplayJson({ storedData, }: GetDisplayJsonParams): AttendeeAddedAuditDisplayData { - const { fields } = this.parseStored({ version: storedData.version, fields: storedData.fields }); + const { fields } = this.parseStored(storedData); const previousAttendeesSet = new Set(fields.attendees.old ?? []); const addedAttendees = fields.attendees.new.filter( (email) => !previousAttendeesSet.has(email) diff --git a/packages/features/booking-audit/lib/actions/AttendeeNoShowUpdatedAuditActionService.ts b/packages/features/booking-audit/lib/actions/AttendeeNoShowUpdatedAuditActionService.ts index 5128fca8414588..3c9c7a3d25c691 100644 --- a/packages/features/booking-audit/lib/actions/AttendeeNoShowUpdatedAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/AttendeeNoShowUpdatedAuditActionService.ts @@ -65,7 +65,7 @@ export class AttendeeNoShowUpdatedAuditActionService implements IAuditActionServ getDisplayJson({ storedData, }: GetDisplayJsonParams): AttendeeNoShowUpdatedAuditDisplayData { - const { fields } = this.parseStored({ version: storedData.version, fields: storedData.fields }); + const { fields } = this.parseStored(storedData); return { noShowAttendee: fields.noShowAttendee.new, previousNoShowAttendee: fields.noShowAttendee.old ?? null, diff --git a/packages/features/booking-audit/lib/actions/AttendeeRemovedAuditActionService.ts b/packages/features/booking-audit/lib/actions/AttendeeRemovedAuditActionService.ts index 86062de959cbbf..5a723df9e7c97f 100644 --- a/packages/features/booking-audit/lib/actions/AttendeeRemovedAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/AttendeeRemovedAuditActionService.ts @@ -65,7 +65,7 @@ export class AttendeeRemovedAuditActionService implements IAuditActionService { getDisplayJson({ storedData, }: GetDisplayJsonParams): AttendeeRemovedAuditDisplayData { - const { fields } = this.parseStored({ version: storedData.version, fields: storedData.fields }); + const { fields } = this.parseStored(storedData); const remainingAttendeesSet = new Set(fields.attendees.new ?? []); const removedAttendees = (fields.attendees.old ?? []).filter( (email) => !remainingAttendeesSet.has(email) diff --git a/packages/features/booking-audit/lib/actions/CancelledAuditActionService.ts b/packages/features/booking-audit/lib/actions/CancelledAuditActionService.ts index 4dcb6f5dfa4a93..2c400b81243d20 100644 --- a/packages/features/booking-audit/lib/actions/CancelledAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/CancelledAuditActionService.ts @@ -67,7 +67,7 @@ export class CancelledAuditActionService implements IAuditActionService { getDisplayJson({ storedData, }: GetDisplayJsonParams): CancelledAuditDisplayData { - const { fields } = this.parseStored({ version: storedData.version, fields: storedData.fields }); + const { fields } = this.parseStored(storedData); return { cancellationReason: fields.cancellationReason ?? null, cancelledBy: fields.cancelledBy ?? null, diff --git a/packages/features/booking-audit/lib/actions/CreatedAuditActionService.ts b/packages/features/booking-audit/lib/actions/CreatedAuditActionService.ts index ad616eba04a376..a8c6cb2fdf2b2a 100644 --- a/packages/features/booking-audit/lib/actions/CreatedAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/CreatedAuditActionService.ts @@ -3,6 +3,7 @@ import { BookingStatus } from "@calcom/prisma/enums"; import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, GetDisplayJsonParams } from "./IAuditActionService"; +import type { UserRepository } from "@calcom/features/users/repositories/UserRepository"; /** * Created Audit Action Service @@ -31,8 +32,7 @@ export class CreatedAuditActionService implements IAuditActionService { // Union of all versions public static readonly storedFieldsSchema = CreatedAuditActionService.fieldsSchemaV1; private helper: AuditActionServiceHelper; - - constructor() { + constructor(private userRepository: UserRepository) { this.helper = new AuditActionServiceHelper({ latestVersion: this.VERSION, latestFieldsSchema: CreatedAuditActionService.latestFieldsSchema, @@ -66,7 +66,7 @@ export class CreatedAuditActionService implements IAuditActionService { storedData, userTimeZone, }: GetDisplayJsonParams): CreatedAuditDisplayData { - const { fields } = this.parseStored({ version: storedData.version, fields: storedData.fields }); + const { fields } = this.parseStored(storedData); const timeZone = userTimeZone; return { diff --git a/packages/features/booking-audit/lib/actions/HostNoShowUpdatedAuditActionService.ts b/packages/features/booking-audit/lib/actions/HostNoShowUpdatedAuditActionService.ts index 8a3877afd8045c..18483f8a40009f 100644 --- a/packages/features/booking-audit/lib/actions/HostNoShowUpdatedAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/HostNoShowUpdatedAuditActionService.ts @@ -65,7 +65,7 @@ export class HostNoShowUpdatedAuditActionService implements IAuditActionService getDisplayJson({ storedData, }: GetDisplayJsonParams): HostNoShowUpdatedAuditDisplayData { - const { fields } = this.parseStored({ version: storedData.version, fields: storedData.fields }); + const { fields } = this.parseStored(storedData); return { noShowHost: fields.noShowHost.new, previousNoShowHost: fields.noShowHost.old ?? null, diff --git a/packages/features/booking-audit/lib/actions/RescheduledAuditActionService.ts b/packages/features/booking-audit/lib/actions/RescheduledAuditActionService.ts index fb28fc86be8a20..a70d16b965ace1 100644 --- a/packages/features/booking-audit/lib/actions/RescheduledAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/RescheduledAuditActionService.ts @@ -120,7 +120,7 @@ export class RescheduledAuditActionService implements IAuditActionService { storedData, userTimeZone, }: GetDisplayJsonParams): RescheduledAuditDisplayData { - const { fields } = this.parseStored({ version: storedData.version, fields: storedData.fields }); + const { fields } = this.parseStored(storedData); const timeZone = userTimeZone; return { diff --git a/packages/features/booking-audit/lib/service/BookingAuditActionServiceRegistry.ts b/packages/features/booking-audit/lib/service/BookingAuditActionServiceRegistry.ts index d5c311f21ea9e4..9d41f928afb5fb 100644 --- a/packages/features/booking-audit/lib/service/BookingAuditActionServiceRegistry.ts +++ b/packages/features/booking-audit/lib/service/BookingAuditActionServiceRegistry.ts @@ -17,6 +17,7 @@ import { LocationChangedAuditActionService, type LocationChangedAuditData } from import { AttendeeNoShowUpdatedAuditActionService, type AttendeeNoShowUpdatedAuditData } from "../actions/AttendeeNoShowUpdatedAuditActionService"; import { SeatBookedAuditActionService, type SeatBookedAuditData } from "../actions/SeatBookedAuditActionService"; import { SeatRescheduledAuditActionService, type SeatRescheduledAuditData } from "../actions/SeatRescheduledAuditActionService"; +import type { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; /** * Union type for all audit action data types @@ -47,6 +48,7 @@ export type AuditActionData = */ interface BookingAuditActionServiceRegistryDeps { userRepository: UserRepository; + bookingRepository: BookingRepository; } export class BookingAuditActionServiceRegistry { @@ -54,7 +56,7 @@ export class BookingAuditActionServiceRegistry { constructor(private deps: BookingAuditActionServiceRegistryDeps) { const services: Array<[BookingAuditAction, IAuditActionService]> = [ - ["CREATED", new CreatedAuditActionService()], + ["CREATED", new CreatedAuditActionService(deps.userRepository)], ["CANCELLED", new CancelledAuditActionService()], ["RESCHEDULED", new RescheduledAuditActionService()], ["ACCEPTED", new AcceptedAuditActionService()], diff --git a/packages/features/booking-audit/lib/service/BookingAuditProducerService.interface.ts b/packages/features/booking-audit/lib/service/BookingAuditProducerService.interface.ts index cb9927a24eed83..9071a57893cee6 100644 --- a/packages/features/booking-audit/lib/service/BookingAuditProducerService.interface.ts +++ b/packages/features/booking-audit/lib/service/BookingAuditProducerService.interface.ts @@ -171,9 +171,6 @@ export interface BookingAuditProducerService { context?: BookingAuditContext; }): Promise; - /** - * Queues a bulk accepted audit task for multiple bookings - */ queueBulkAcceptedAudit(params: { bookings: Array<{ bookingUid: string; @@ -186,9 +183,6 @@ export interface BookingAuditProducerService { context?: BookingAuditContext; }): Promise; - /** - * Queues a bulk cancelled audit task for multiple bookings - */ queueBulkCancelledAudit(params: { bookings: Array<{ bookingUid: string; @@ -200,5 +194,27 @@ export interface BookingAuditProducerService { operationId?: string | null; context?: BookingAuditContext; }): Promise; + + queueBulkCreatedAudit(params: { + bookings: Array<{ + bookingUid: string; + data: z.infer; + }>; + actor: Actor; + organizationId: number | null; + source: ActionSource; + operationId?: string | null; + }): Promise; + + queueBulkRescheduledAudit(params: { + bookings: Array<{ + bookingUid: string; + data: z.infer; + }>; + actor: Actor; + organizationId: number | null; + source: ActionSource; + operationId?: string | null; + }): Promise; } diff --git a/packages/features/booking-audit/lib/service/BookingAuditTaskConsumer.ts b/packages/features/booking-audit/lib/service/BookingAuditTaskConsumer.ts index 66440d3adea976..607ff095103fd1 100644 --- a/packages/features/booking-audit/lib/service/BookingAuditTaskConsumer.ts +++ b/packages/features/booking-audit/lib/service/BookingAuditTaskConsumer.ts @@ -3,6 +3,7 @@ import type { JsonValue } from "@calcom/types/Json"; import logger from "@calcom/lib/logger"; import type { IFeaturesRepository } from "@calcom/features/flags/features.repository.interface"; import type { UserRepository } from "@calcom/features/users/repositories/UserRepository"; +import type { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; import type { PiiFreeActor, BookingAuditContext } from "../dto/types"; import type { @@ -22,6 +23,7 @@ interface BookingAuditTaskConsumerDeps { auditActorRepository: IAuditActorRepository; featuresRepository: IFeaturesRepository; userRepository: UserRepository; + bookingRepository: BookingRepository; } type CreateBookingAuditInput = { @@ -78,7 +80,7 @@ export class BookingAuditTaskConsumer { this.userRepository = deps.userRepository; // Centralized registry for all action services - this.actionServiceRegistry = new BookingAuditActionServiceRegistry({ userRepository: this.userRepository }); + this.actionServiceRegistry = new BookingAuditActionServiceRegistry({ userRepository: this.userRepository, bookingRepository: deps.bookingRepository }); } /** diff --git a/packages/features/booking-audit/lib/service/BookingAuditTaskerProducerService.ts b/packages/features/booking-audit/lib/service/BookingAuditTaskerProducerService.ts index 5821bab4f135c8..64874fcddf380b 100644 --- a/packages/features/booking-audit/lib/service/BookingAuditTaskerProducerService.ts +++ b/packages/features/booking-audit/lib/service/BookingAuditTaskerProducerService.ts @@ -401,4 +401,36 @@ export class BookingAuditTaskerProducerService implements BookingAuditProducerSe action: CancelledAuditActionService.TYPE, }); } + + async queueBulkCreatedAudit(params: { + bookings: Array<{ + bookingUid: string; + data: z.infer; + }>; + actor: Actor; + organizationId: number | null; + source: ActionSource; + operationId?: string | null; + }): Promise { + await this.queueBulkTask({ + ...params, + action: CreatedAuditActionService.TYPE, + }); + } + + async queueBulkRescheduledAudit(params: { + bookings: Array<{ + bookingUid: string; + data: z.infer; + }>; + actor: Actor; + organizationId: number | null; + source: ActionSource; + operationId?: string | null; + }): Promise { + await this.queueBulkTask({ + ...params, + action: RescheduledAuditActionService.TYPE, + }); + } } diff --git a/packages/features/booking-audit/lib/service/BookingAuditViewerService.ts b/packages/features/booking-audit/lib/service/BookingAuditViewerService.ts index cb7f3dfafdaff8..2f205b74f4d3e1 100644 --- a/packages/features/booking-audit/lib/service/BookingAuditViewerService.ts +++ b/packages/features/booking-audit/lib/service/BookingAuditViewerService.ts @@ -1,9 +1,10 @@ import type { UserRepository } from "@calcom/features/users/repositories/UserRepository"; import type { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; import type { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository"; +import type { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository"; + import type { IAttendeeRepository } from "@calcom/features/bookings/repositories/IAttendeeRepository"; import type { ISimpleLogger } from "@calcom/features/di/shared/services/logger.service"; -import type { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository"; import { BookingAuditActionServiceRegistry } from "./BookingAuditActionServiceRegistry"; import { BookingAuditAccessService } from "./BookingAuditAccessService"; import type { IBookingAuditRepository, BookingAuditWithActor, BookingAuditAction, BookingAuditType } from "../repository/IBookingAuditRepository"; @@ -84,7 +85,7 @@ export class BookingAuditViewerService { bookingRepository: this.bookingRepository, membershipRepository: this.membershipRepository, }); - this.actionServiceRegistry = new BookingAuditActionServiceRegistry({ userRepository: this.userRepository }); + this.actionServiceRegistry = new BookingAuditActionServiceRegistry({ userRepository: this.userRepository, bookingRepository: this.bookingRepository }); } /** @@ -125,9 +126,7 @@ export class BookingAuditViewerService { userTimeZone, }); if (rescheduledFromLog) { - // Add the rescheduled log from the previous booking as the first entry - // (appears last chronologically since logs are ordered by timestamp DESC) - enrichedAuditLogs.unshift(rescheduledFromLog); + enrichedAuditLogs.push(rescheduledFromLog); } } diff --git a/packages/features/booking-audit/todo.md b/packages/features/booking-audit/todo.md new file mode 100644 index 00000000000000..f37a5e50c94437 --- /dev/null +++ b/packages/features/booking-audit/todo.md @@ -0,0 +1,3 @@ +- Created Action's description could be changed to something like 'Booked with ' +- Add FormResponse Audit too starting with CREATED action to connect it with the booking that was done thrugh it +- Rerouting should be recorded properly instead of recording it as reschedule \ No newline at end of file diff --git a/packages/features/bookings/di/RecurringBookingService.module.ts b/packages/features/bookings/di/RecurringBookingService.module.ts index c6846da3f3bd0c..06f1f69a8a419c 100644 --- a/packages/features/bookings/di/RecurringBookingService.module.ts +++ b/packages/features/bookings/di/RecurringBookingService.module.ts @@ -3,6 +3,7 @@ import { createModule, bindModuleToClassOnToken } from "@calcom/features/di/di"; import { DI_TOKENS } from "@calcom/features/di/tokens"; import { moduleLoader as regularBookingServiceModuleLoader } from "./RegularBookingService.module"; +import { moduleLoader as bookingEventHandlerModuleLoader } from "./BookingEventHandlerService.module"; const token = DI_TOKENS.RECURRING_BOOKING_SERVICE; const moduleToken = DI_TOKENS.RECURRING_BOOKING_SERVICE_MODULE; @@ -15,6 +16,7 @@ const loadModule = bindModuleToClassOnToken({ classs: RecurringBookingService, depsMap: { regularBookingService: regularBookingServiceModuleLoader, + bookingEventHandler: bookingEventHandlerModuleLoader, }, }); diff --git a/packages/features/bookings/lib/dto/types.d.ts b/packages/features/bookings/lib/dto/types.d.ts index 47161ca3e79095..d3ed978b24d5b1 100644 --- a/packages/features/bookings/lib/dto/types.d.ts +++ b/packages/features/bookings/lib/dto/types.d.ts @@ -34,6 +34,7 @@ export type PlatformParams = { export type CreateBookingMeta = { userId?: number; + userUuid?: string; // These used to come from headers but now we're passing them as params hostname?: string; forcedSlug?: string; diff --git a/packages/features/bookings/lib/getBookingToDelete.ts b/packages/features/bookings/lib/getBookingToDelete.ts index ecc33ee3d019dc..3d16047cb6dd09 100644 --- a/packages/features/bookings/lib/getBookingToDelete.ts +++ b/packages/features/bookings/lib/getBookingToDelete.ts @@ -112,6 +112,8 @@ export async function getBookingToDelete(id: number | undefined, uid: string | u iCalUID: true, iCalSequence: true, status: true, + cancellationReason: true, + cancelledBy: true, }, }); } diff --git a/packages/features/bookings/lib/handleCancelBooking.ts b/packages/features/bookings/lib/handleCancelBooking.ts index 19ffa6e88ea1ef..0c94e9b4f6d155 100644 --- a/packages/features/bookings/lib/handleCancelBooking.ts +++ b/packages/features/bookings/lib/handleCancelBooking.ts @@ -70,6 +70,9 @@ type PlatformParams = { export type BookingToDelete = Awaited>; export type CancelBookingInput = { + /** + * @deprecated Use userUuid instead + */ userId?: number; userUuid?: string; bookingData: z.infer; @@ -101,12 +104,9 @@ function getAuditActor({ bookingUid, }) ); - // Having fallback prefix makes it clear that we created guest actor from fallback logic actorEmail = buildActorEmail({ identifier: getUniqueIdentifier({ prefix: "fallback" }), actorType: "guest" }); } else { - // We can't trust cancelledByEmail and thus can't reuse it as is because it can be set anything by anyone. If we use that as guest actor, we could accidentally attribute the action to the wrong guest actor. - // Having param prefix makes it clear that we created guest actor from query param and we still don't use the email as is. actorEmail = buildActorEmail({ identifier: getUniqueIdentifier({ prefix: "param" }), actorType: "guest" }); } @@ -129,6 +129,7 @@ async function handler(input: CancelBookingInput) { const bookingToDelete = await getBookingToDelete(id, uid); const { userId, + userUuid, platformBookingUrl, platformCancelUrl, platformClientId, @@ -136,19 +137,19 @@ async function handler(input: CancelBookingInput) { arePlatformEmailsEnabled, } = input; - const userUuid = input.userUuid ?? null; + const userUuidValue = userUuid ?? null; // Extract action source once for reuse const actionSource = input.actionSource ?? "UNKNOWN"; if (actionSource === "UNKNOWN") { log.warn("Booking cancellation with unknown actionSource", safeStringify({ bookingUid: bookingToDelete.uid, - userUuid, + userUuid: userUuidValue, })); } const actorToUse = getAuditActor({ - userUuid, + userUuid: userUuidValue, cancelledByEmailInQueryParam: cancelledBy ?? null, bookingUid: bookingToDelete.uid, }); diff --git a/packages/features/bookings/lib/handleConfirmation.ts b/packages/features/bookings/lib/handleConfirmation.ts index 548b6f28f03a5b..170aaced839c7f 100644 --- a/packages/features/bookings/lib/handleConfirmation.ts +++ b/packages/features/bookings/lib/handleConfirmation.ts @@ -31,13 +31,17 @@ import type { PlatformClientParams } from "@calcom/prisma/zod-utils"; import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; import type { AdditionalInformation, CalendarEvent } from "@calcom/types/Calendar"; +import { v4 as uuidv4 } from "uuid"; + import { getCalEventResponses } from "./getCalEventResponses"; +import type { AcceptedAuditData } from "@calcom/features/booking-audit/lib/actions/AcceptedAuditActionService"; +import { getBookingEventHandlerService } from "@calcom/features/bookings/di/BookingEventHandlerService.container"; import { scheduleNoShowTriggers } from "./handleNewBooking/scheduleNoShowTriggers"; - -const log = logger.getSubLogger({ prefix: ["[handleConfirmation] book:user"] }); +import type { Actor } from "@calcom/features/booking-audit/lib/dto/types"; +import type { ActionSource } from "@calcom/features/booking-audit/lib/types/actionSource"; export async function handleConfirmation(args: { - user: EventManagerUser & { username: string | null }; + user: EventManagerUser & { username: string | null; uuid: string }; evt: CalendarEvent; recurringEventId?: string; prisma: PrismaClient; @@ -46,6 +50,7 @@ export async function handleConfirmation(args: { startTime: Date; id: number; uid: string; + status: BookingStatus; eventType: { currency: string; description: string | null; @@ -76,6 +81,8 @@ export async function handleConfirmation(args: { paid?: boolean; emailsEnabled?: boolean; platformClientParams?: PlatformClientParams; + source: ActionSource; + actor: Actor; traceContext: TraceContext; }) { const { @@ -261,6 +268,38 @@ export async function handleConfirmation(args: { const updatedBookingsResult = await Promise.all(updateBookingsPromise); updatedBookings = updatedBookings.concat(updatedBookingsResult); + + // Audit each recurring booking acceptance + const bookingEventHandlerService = getBookingEventHandlerService(); + const recurringAuditTeamId = await getTeamIdFromEventType({ + eventType: { + team: { id: booking.eventType?.teamId ?? null }, + parentId: booking.eventType?.parentId ?? null, + }, + }); + const recurringAuditTriggerForUser = + !recurringAuditTeamId || (recurringAuditTeamId && booking.eventType?.parentId); + const recurringAuditUserId = recurringAuditTriggerForUser ? booking.userId : null; + const recurringAuditOrgId = await getOrgIdFromMemberOrTeamId({ + memberId: recurringAuditUserId, + teamId: recurringAuditTeamId, + }); + const operationId = uuidv4(); + await bookingEventHandlerService.onBulkBookingsAccepted({ + bookings: updatedBookingsResult.map((updatedRecurringBooking) => ({ + bookingUid: updatedRecurringBooking.uid, + auditData: { + status: { + old: BookingStatus.PENDING, + new: BookingStatus.ACCEPTED, + }, + }, + })), + actor: args.actor, + organizationId: recurringAuditOrgId ?? null, + operationId, + source: args.source, + }); } else { // @NOTE: be careful with this as if any error occurs before this booking doesn't get confirmed // Should perform update on booking (confirm) -> then trigger the rest handlers @@ -321,6 +360,31 @@ export async function handleConfirmation(args: { }, }); updatedBookings.push(updatedBooking); + + const bookingEventHandlerService = getBookingEventHandlerService(); + const auditData: AcceptedAuditData = { + status: { + old: booking.status, + new: BookingStatus.ACCEPTED, + }, + }; + const auditTeamId = await getTeamIdFromEventType({ + eventType: { + team: { id: booking.eventType?.teamId ?? null }, + parentId: booking.eventType?.parentId ?? null, + }, + }); + const auditTriggerForUser = !auditTeamId || (auditTeamId && booking.eventType?.parentId); + const auditUserId = auditTriggerForUser ? booking.userId : null; + const auditOrgId = await getOrgIdFromMemberOrTeamId({ memberId: auditUserId, teamId: auditTeamId }); + await bookingEventHandlerService.onBookingAccepted({ + bookingUid: updatedBooking.uid, + actor: args.actor, + organizationId: auditOrgId ?? null, + operationId: null, + auditData, + source: args.source, + }); } const teamId = await getTeamIdFromEventType({ diff --git a/packages/features/bookings/lib/handleNewBooking/buildBookingEventPayload.ts b/packages/features/bookings/lib/handleNewBooking/buildBookingEventPayload.ts new file mode 100644 index 00000000000000..6c58d27c7f15e6 --- /dev/null +++ b/packages/features/bookings/lib/handleNewBooking/buildBookingEventPayload.ts @@ -0,0 +1,43 @@ +import type { BookingStatus } from "@calcom/prisma/enums"; + + +export const buildBookingCreatedAuditData = ({ booking }: { + booking: { + startTime: Date; + endTime: Date; + status: BookingStatus; + } +}) => { + return { + startTime: booking.startTime.getTime(), + endTime: booking.endTime.getTime(), + status: booking.status, + } +}; + +export const buildBookingRescheduledAuditData = ({ oldBooking, newBooking }: { + oldBooking: { + startTime: Date; + endTime: Date; + }; + newBooking: { + startTime: Date; + endTime: Date; + uid: string; + }; +}) => { + return { + startTime: { + old: oldBooking.startTime.toISOString() ?? null, + new: newBooking.startTime.toISOString(), + }, + endTime: { + old: oldBooking.endTime.toISOString() ?? null, + new: newBooking.endTime.toISOString(), + }, + rescheduledToUid: { + old: null, + new: newBooking.uid, + }, + } +}; \ No newline at end of file diff --git a/packages/features/bookings/lib/handleNewBooking/getAuditActionSource.ts b/packages/features/bookings/lib/handleNewBooking/getAuditActionSource.ts new file mode 100644 index 00000000000000..c152a12852ccb4 --- /dev/null +++ b/packages/features/bookings/lib/handleNewBooking/getAuditActionSource.ts @@ -0,0 +1,21 @@ +import { CreationSource } from "@calcom/prisma/enums"; +import type { ActionSource } from "@calcom/features/booking-audit/lib/types/actionSource"; +import { criticalLogger } from "@calcom/lib/logger.server"; + +export const getAuditActionSource = ({ creationSource, eventTypeId, rescheduleUid }: { creationSource: CreationSource | null | undefined, eventTypeId: number, rescheduleUid: string | null }): ActionSource => { + if (creationSource === CreationSource.API_V1) { + return "API_V1"; + } + if (creationSource === CreationSource.API_V2) { + return "API_V2"; + } + if (creationSource === CreationSource.WEBAPP) { + return "WEBAPP"; + } + // Unknown creationSource - log for tracking and fix + criticalLogger.warn("Unknown booking creationSource detected", { + eventTypeId, + rescheduleUid, + }); + return "UNKNOWN"; +}; \ No newline at end of file diff --git a/packages/features/bookings/lib/handleNewBooking/getBookingAuditActorForNewBooking.ts b/packages/features/bookings/lib/handleNewBooking/getBookingAuditActorForNewBooking.ts new file mode 100644 index 00000000000000..aa5b53f9d1b947 --- /dev/null +++ b/packages/features/bookings/lib/handleNewBooking/getBookingAuditActorForNewBooking.ts @@ -0,0 +1,34 @@ +import { makeAttendeeActor, makeUserActor, makeGuestActor } from "@calcom/features/booking-audit/lib/makeActor"; +import { ISimpleLogger } from "@calcom/features/di/shared/services/logger.service"; +import { safeStringify } from "@calcom/lib/safeStringify"; +/** + * Used to create actor for new booking/reschedule booking scenarios + */ +export function getBookingAuditActorForNewBooking({ + attendeeId, + userUuid, + bookerEmail, + logger, + bookerName, +}: { + attendeeId: number | null; + userUuid: string | null; + bookerEmail: string; + bookerName: string; + logger: ISimpleLogger; +}) { + if (userUuid) { + return makeUserActor(userUuid); + } + + if (attendeeId) { + return makeAttendeeActor(attendeeId); + } + + // Ideally we should have attendeeId atleast but for some unforeseen case we don't, we must create a guest actor. + logger.warn("Creating guest actor for booking audit", safeStringify({ + email: bookerEmail, + })); + + return makeGuestActor({ email: bookerEmail, name: bookerName }); +} diff --git a/packages/features/bookings/lib/handleSeats/handleSeats.ts b/packages/features/bookings/lib/handleSeats/handleSeats.ts index ecc37f4815d198..796fe23b3d079a 100644 --- a/packages/features/bookings/lib/handleSeats/handleSeats.ts +++ b/packages/features/bookings/lib/handleSeats/handleSeats.ts @@ -12,7 +12,7 @@ import { createLoggerWithEventDetails } from "../handleNewBooking/logger"; import createNewSeat from "./create/createNewSeat"; import rescheduleSeatedBooking from "./reschedule/rescheduleSeatedBooking"; import type { NewSeatedBookingObject, SeatedBooking, HandleSeatsResultBooking } from "./types"; - +import { getBookingAuditActorForNewBooking } from "../handleNewBooking/getBookingAuditActorForNewBooking"; const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => { const { eventType, @@ -34,6 +34,10 @@ const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => { rescheduledBy, rescheduleReason, isDryRun = false, + bookingEventHandler, + organizationId, + userUuid, + fullName, traceContext, } = newSeatedBookingObject; // TODO: We could allow doing more things to support good dry run for seats @@ -89,6 +93,29 @@ const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => { throw new HttpError({ statusCode: 409, message: ErrorCode.AlreadySignedUpForBooking }); } + // Helper function to get audit actor with logging for guest actors + const getAuditActorForSeats = (): import("@calcom/features/booking-audit/lib/dto/types").Actor => { + return getBookingAuditActorForNewBooking({ + attendeeId: null, + userUuid: userUuid ?? null, + bookerEmail, + bookerName: fullName, + logger: loggerWithEventDetails, + }); + }; + + // Use actionSource from parameter, defaulting to UNKNOWN to avoid wrong attribution + const actionSource = newSeatedBookingObject.actionSource ?? "UNKNOWN"; + + if (actionSource === "UNKNOWN") { + loggerWithEventDetails.warn("Seat booking/reschedule called with unknown actionSource", { + eventTypeId: eventType.id, + rescheduleUid, + reqBookingUid, + bookerEmail, + }); + } + // There are two paths here, reschedule a booking with seats and booking seats without reschedule if (rescheduleUid) { resultBooking = await rescheduleSeatedBooking( @@ -98,8 +125,69 @@ const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => { resultBooking, loggerWithEventDetails ); + + // Log SEAT_RESCHEDULED audit event + if (bookingEventHandler && resultBooking && originalRescheduledBooking) { + const auditActor = getAuditActorForSeats(); + const seatReferenceUid = resultBooking.seatReferenceUid; + if (seatReferenceUid) { + // Determine if seat moved to a different booking (different time slot) + const movedToDifferentBooking = resultBooking.uid && resultBooking.uid !== seatedBooking.uid; + const newBookingStartTime = movedToDifferentBooking && resultBooking.startTime + ? new Date(resultBooking.startTime).toISOString() + : seatedBooking.startTime.toISOString(); + const newBookingEndTime = movedToDifferentBooking && resultBooking.endTime + ? new Date(resultBooking.endTime).toISOString() + : seatedBooking.endTime.toISOString(); + + await bookingEventHandler.onSeatRescheduled({ + bookingUid: seatedBooking.uid, + actor: auditActor, + organizationId: organizationId ?? null, + auditData: { + seatReferenceUid, + attendeeEmail: bookerEmail, + startTime: { + old: originalRescheduledBooking.startTime.toISOString(), + new: newBookingStartTime, + }, + endTime: { + old: originalRescheduledBooking.endTime.toISOString(), + new: newBookingEndTime, + }, + rescheduledToBookingUid: { + old: null, + new: movedToDifferentBooking ? (resultBooking.uid || null) : null, + }, + }, + source: actionSource, + }); + } + } } else { resultBooking = await createNewSeat(newSeatedBookingObject, seatedBooking, reqBodyMetadata); + + // Log SEAT_BOOKED audit event + if (bookingEventHandler && resultBooking) { + const auditActor = getAuditActorForSeats(); + const seatReferenceUid = resultBooking.seatReferenceUid; + if (seatReferenceUid) { + await bookingEventHandler.onSeatBooked({ + bookingUid: seatedBooking.uid, + actor: auditActor, + organizationId: organizationId ?? null, + auditData: { + seatReferenceUid, + attendeeEmail: bookerEmail, + attendeeName: fullName || bookerEmail, + startTime: seatedBooking.startTime.getTime(), + endTime: seatedBooking.endTime.getTime(), + }, + source: actionSource, + } + ); + } + } } // If the resultBooking is defined we should trigger workflows else, trigger in handleNewBooking diff --git a/packages/features/bookings/lib/handleSeats/types.d.ts b/packages/features/bookings/lib/handleSeats/types.d.ts index 64dca252ad1d84..b8f4bb25715926 100644 --- a/packages/features/bookings/lib/handleSeats/types.d.ts +++ b/packages/features/bookings/lib/handleSeats/types.d.ts @@ -5,6 +5,8 @@ import type { TraceContext } from "@calcom/lib/tracing"; import type { Prisma } from "@calcom/prisma/client"; import type { AppsStatus, CalendarEvent } from "@calcom/types/Calendar"; +import type { BookingEventHandlerService } from "../../onBookingEvents/BookingEventHandlerService"; +import type { ActionSource } from "@calcom/features/booking-audit/lib/types/actionSource"; import type { Booking } from "../handleNewBooking/createBooking"; import type { NewBookingEventType } from "../handleNewBooking/getEventTypesFromDB"; import type { OriginalRescheduledBooking } from "../handleNewBooking/originalRescheduledBookingUtils"; @@ -39,6 +41,7 @@ export type NewSeatedBookingObject = { tAttendees: TFunction; bookingSeat: BookingSeat; reqUserId: number | undefined; + userUuid?: string | null; rescheduleReason: RescheduleReason; reqBodyUser: string | string[] | undefined; noEmail: NoEmail; @@ -59,6 +62,9 @@ export type NewSeatedBookingObject = { rescheduledBy?: string; workflows: Workflow[]; isDryRun?: boolean; + bookingEventHandler?: BookingEventHandlerService; + organizationId?: number | null; + actionSource?: ActionSource; traceContext: TraceContext; }; @@ -81,12 +87,12 @@ export type SeatedBooking = Prisma.BookingGetPayload<{ export type HandleSeatsResultBooking = | (Partial & { - appsStatus?: AppsStatus[]; - seatReferenceUid?: string; - paymentUid?: string; - message?: string; - paymentId?: number; - }) + appsStatus?: AppsStatus[]; + seatReferenceUid?: string; + paymentUid?: string; + message?: string; + paymentId?: number; + }) | null; export type NewTimeSlotBooking = Prisma.BookingGetPayload<{ diff --git a/packages/features/bookings/lib/onBookingEvents/BookingEventHandlerService.ts b/packages/features/bookings/lib/onBookingEvents/BookingEventHandlerService.ts index ee8974b0173d8b..1c7e498cf57968 100644 --- a/packages/features/bookings/lib/onBookingEvents/BookingEventHandlerService.ts +++ b/packages/features/bookings/lib/onBookingEvents/BookingEventHandlerService.ts @@ -97,9 +97,6 @@ export class BookingEventHandlerService { async onBookingRescheduled(params: OnBookingRescheduledParams) { const { payload, actor, auditData, source, operationId, context } = params; this.log.debug("onBookingRescheduled", safeStringify(payload)); - if (payload.config.isDryRun) { - return; - } await this.onBookingCreatedOrRescheduled(payload); await this.bookingAuditProducerService.queueRescheduledAudit({ @@ -356,4 +353,54 @@ export class BookingEventHandlerService { context, }); } + + async onBulkBookingsCreated(params: { + bookings: Array<{ + bookingUid: string; + auditData: CreatedAuditData; + }>; + actor: Actor; + organizationId: number | null; + operationId?: string | null; + source: ActionSource; + }) { + const { bookings, actor, organizationId, operationId, source } = params; + await this.bookingAuditProducerService.queueBulkCreatedAudit({ + bookings: bookings.map((booking) => ({ + bookingUid: booking.bookingUid, + data: booking.auditData, + })), + actor, + organizationId, + source, + operationId, + }); + } + + /** + * Handles bulk booking rescheduling for recurring bookings + * Creates a single task that will be processed to create multiple audit logs atomically + */ + async onBulkBookingsRescheduled(params: { + bookings: Array<{ + bookingUid: string; + auditData: RescheduledAuditData; + }>; + actor: Actor; + organizationId: number | null; + operationId?: string | null; + source: ActionSource; + }) { + const { bookings, actor, organizationId, operationId, source } = params; + await this.bookingAuditProducerService.queueBulkRescheduledAudit({ + bookings: bookings.map((booking) => ({ + bookingUid: booking.bookingUid, + data: booking.auditData, + })), + actor, + organizationId, + source, + operationId, + }); + } } diff --git a/packages/features/bookings/lib/payment/getBooking.ts b/packages/features/bookings/lib/payment/getBooking.ts index 25d5a035b6193a..ca151eefc2486e 100644 --- a/packages/features/bookings/lib/payment/getBooking.ts +++ b/packages/features/bookings/lib/payment/getBooking.ts @@ -102,6 +102,7 @@ export async function getBooking(bookingId: number) { user: { select: { id: true, + uuid: true, username: true, timeZone: true, credentials: { select: credentialForCalendarServiceSelect }, diff --git a/packages/features/bookings/lib/service/RecurringBookingService.ts b/packages/features/bookings/lib/service/RecurringBookingService.ts index 3a1cf759ba2c57..4c5c68d147dfc1 100644 --- a/packages/features/bookings/lib/service/RecurringBookingService.ts +++ b/packages/features/bookings/lib/service/RecurringBookingService.ts @@ -1,19 +1,25 @@ import type { CreateBookingMeta, CreateRecurringBookingData } from "@calcom/features/bookings/lib/dto/types"; import type { BookingResponse } from "@calcom/features/bookings/types"; -import { SchedulingType } from "@calcom/prisma/enums"; +import { CreationSource, SchedulingType } from "@calcom/prisma/enums"; import type { AppsStatus } from "@calcom/types/Calendar"; - +import { v4 as uuidv4 } from "uuid"; +import type { BookingStatus } from "@calcom/prisma/enums"; import type { IBookingService } from "../interfaces/IBookingService"; import type { RegularBookingService } from "./RegularBookingService"; - +import type { BookingEventHandlerService } from "../onBookingEvents/BookingEventHandlerService"; +import { getBookingAuditActorForNewBooking } from "../handleNewBooking/getBookingAuditActorForNewBooking"; +import { criticalLogger } from "@calcom/lib/logger.server"; +import { getAuditActionSource } from "../handleNewBooking/getAuditActionSource"; +import { buildBookingCreatedAuditData, buildBookingRescheduledAuditData } from "../handleNewBooking/buildBookingEventPayload"; export type BookingHandlerInput = { bookingData: CreateRecurringBookingData; } & CreateBookingMeta; -export const handleNewRecurringBooking = async ( +export const handleNewRecurringBooking = async function ( + this: RecurringBookingService, input: BookingHandlerInput, deps: IRecurringBookingServiceDependencies -): Promise => { +): Promise { const data = input.bookingData; const { regularBookingService } = deps; const createdBookings: BookingResponse[] = []; @@ -121,25 +127,107 @@ export const handleNewRecurringBooking = async ( } } } + + if (createdBookings.length > 0) { + await this.fireBookingEvents({ + createdBookings, + eventTypeId: firstBooking.eventTypeId, + rescheduleUid: firstBooking.rescheduleUid ?? null, + userUuid: input.userUuid ?? null, + creationSource: firstBooking.creationSource, + }); + } + return createdBookings; }; export interface IRecurringBookingServiceDependencies { regularBookingService: RegularBookingService; + bookingEventHandler: BookingEventHandlerService; } /** * Recurring Booking Service takes care of creating/rescheduling recurring bookings. */ export class RecurringBookingService implements IBookingService { - constructor(private readonly deps: IRecurringBookingServiceDependencies) {} + constructor(private readonly deps: IRecurringBookingServiceDependencies) { } + + async fireBookingEvents({ createdBookings, eventTypeId, rescheduleUid, userUuid, creationSource }: { createdBookings: BookingResponse[], eventTypeId: number, rescheduleUid: string | null, userUuid: string | null, creationSource: CreationSource | undefined }) { + type ValidBooking = BookingResponse & { uid: string; startTime: Date; endTime: Date; status: BookingStatus }; + type ValidRescheduledBooking = ValidBooking & { previousBooking: ValidBooking & { status: BookingStatus } }; + + const isReschedule = !!rescheduleUid; + const firstCreatedBooking = createdBookings[0]; + const eventOrganizationId = firstCreatedBooking.organizationId; + const booker = firstCreatedBooking.attendees?.[0]; + const bookerAttendeeId = booker?.id; + const bookerName = booker?.name || ""; + const bookerEmail = booker?.email || ""; + + const auditActor = getBookingAuditActorForNewBooking({ + attendeeId: bookerAttendeeId ?? null, + userUuid, + bookerEmail, + bookerName, + logger: criticalLogger, + }); + + const actionSource = getAuditActionSource({ creationSource, eventTypeId, rescheduleUid }); + + const operationId = uuidv4(); + + function isValidBooking(booking: BookingResponse): booking is ValidBooking { + return !!(booking.uid && booking.startTime && booking.endTime && booking.status) + } + + function isValidRescheduledBooking(booking: BookingResponse): booking is ValidRescheduledBooking { + return !!(booking.previousBooking && booking.previousBooking.uid && booking.previousBooking.startTime && booking.previousBooking.endTime) + } + + if (isReschedule) { + const bulkRescheduledBookings = createdBookings + .filter(isValidRescheduledBooking) + .map((booking) => ({ + bookingUid: booking.previousBooking.uid, + auditData: buildBookingRescheduledAuditData({ oldBooking: booking.previousBooking, newBooking: booking }), + })); + + if (bulkRescheduledBookings.length > 0) { + await this.deps.bookingEventHandler.onBulkBookingsRescheduled({ + bookings: bulkRescheduledBookings, + actor: auditActor, + organizationId: eventOrganizationId, + operationId, + source: actionSource, + }); + } + } else { + // For new bookings + const bulkCreatedBookings = createdBookings + .filter(isValidBooking) + .map((booking) => ({ + bookingUid: booking.uid, + auditData: buildBookingCreatedAuditData({ booking }), + })); + + if (bulkCreatedBookings.length > 0) { + await this.deps.bookingEventHandler.onBulkBookingsCreated({ + bookings: bulkCreatedBookings, + actor: auditActor, + organizationId: eventOrganizationId, + operationId, + source: actionSource, + }); + } + } + } async createBooking(input: { bookingData: CreateRecurringBookingData; bookingMeta?: CreateBookingMeta; }): Promise { const handlerInput = { bookingData: input.bookingData, ...(input.bookingMeta || {}) }; - return handleNewRecurringBooking(handlerInput, this.deps); + return handleNewRecurringBooking.bind(this)(handlerInput, this.deps); } async rescheduleBooking(input: { @@ -147,6 +235,6 @@ export class RecurringBookingService implements IBookingService { bookingMeta?: CreateBookingMeta; }): Promise { const handlerInput = { bookingData: input.bookingData, ...(input.bookingMeta || {}) }; - return handleNewRecurringBooking(handlerInput, this.deps); + return handleNewRecurringBooking.bind(this)(handlerInput, this.deps); } } diff --git a/packages/features/bookings/lib/service/RegularBookingService.ts b/packages/features/bookings/lib/service/RegularBookingService.ts index d4d5cac860a8e1..2a8e89eb7dfba9 100644 --- a/packages/features/bookings/lib/service/RegularBookingService.ts +++ b/packages/features/bookings/lib/service/RegularBookingService.ts @@ -1,6 +1,8 @@ import short, { uuid } from "short-uuid"; import { v5 as uuidv5 } from "uuid"; - +import { getAuditActionSource } from "../handleNewBooking/getAuditActionSource"; +import { buildBookingCreatedAuditData, buildBookingRescheduledAuditData } from "../handleNewBooking/buildBookingEventPayload"; +import type { ActionSource } from "@calcom/features/booking-audit/lib/types/actionSource"; import processExternalId from "@calcom/app-store/_utils/calendars/processExternalId"; import { getPaymentAppData } from "@calcom/app-store/_utils/payments/getPaymentAppData"; import { @@ -127,6 +129,7 @@ import { validateBookingTimeIsNotOutOfBounds } from "../handleNewBooking/validat import { validateEventLength } from "../handleNewBooking/validateEventLength"; import handleSeats from "../handleSeats/handleSeats"; import type { IBookingService } from "../interfaces/IBookingService"; +import { getBookingAuditActorForNewBooking } from "../handleNewBooking/getBookingAuditActorForNewBooking"; import { isWithinMinimumRescheduleNotice } from "../reschedule/isWithinMinimumRescheduleNotice"; import { makeGuestActor } from "@calcom/features/booking-audit/lib/makeActor"; @@ -560,6 +563,7 @@ async function getEventOrganizationId({ } async function handler( + this: RegularBookingService, input: BookingHandlerInput, deps: IBookingServiceDependencies, bookingDataSchemaGetter: BookingDataSchemaGetter = getBookingDataSchema @@ -567,6 +571,7 @@ async function handler( const { bookingData: rawBookingData, userId, + userUuid, platformClientId, platformCancelUrl, platformBookingUrl, @@ -788,6 +793,12 @@ async function handler( ...(isDryRun ? { troubleshooterData } : {}), paymentUid: firstPayment?.uid, paymentId: firstPayment?.id, + organizationId: eventOrganizationId, + previousBooking: originalRescheduledBooking ? { + uid: originalRescheduledBooking.uid, + startTime: originalRescheduledBooking.startTime, + endTime: originalRescheduledBooking.endTime, + } : null, }; } } @@ -1681,10 +1692,18 @@ async function handler( luckyUsers: [], paymentId: undefined, seatReferenceUid: undefined, - isShortCircuitedBooking: true, // Renamed from isSpamDecoy to avoid exposing spam detection to blocked users + 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({ @@ -1701,6 +1720,7 @@ async function handler( tAttendees, bookingSeat, reqUserId: input.userId, + userUuid, rescheduleReason, reqBodyUser: reqBody.user, noEmail, @@ -1721,6 +1741,9 @@ async function handler( workflows, rescheduledBy: reqBody.rescheduledBy, isDryRun, + bookingEventHandler: deps.bookingEventHandler, + organizationId: eventOrganizationId, + actionSource, traceContext, }); @@ -1738,6 +1761,12 @@ async function handler( 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 @@ -2360,64 +2389,22 @@ async function handler( } : undefined; - const bookingCreatedPayload = buildBookingCreatedPayload({ + await this.fireBookingEvents({ booking, - organizerUserId: organizerUser.id, + 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, - organizationId: eventOrganizationId, + eventOrganizationId, + bookerEmail, + fullName, + userUuid: userUuid ?? null, + originalRescheduledBooking, + actionSource, + isRecurringBooking: !!input.bookingData.allRecurringDates, + tracingLogger, }); - const bookingEventHandler = deps.bookingEventHandler; - // TODO: Identify action source correctly - const actionSource = 'WEBAPP'; - // TODO: We need to check session in booking flow and accordingly create USER actor if applicable. - const auditActor = makeGuestActor({ email: bookerEmail, name: fullName }); - - if (originalRescheduledBooking) { - const bookingRescheduledPayload: BookingRescheduledPayload = { - ...bookingCreatedPayload, - oldBooking: { - uid: originalRescheduledBooking.uid, - startTime: originalRescheduledBooking.startTime, - endTime: originalRescheduledBooking.endTime, - }, - }; - await bookingEventHandler.onBookingRescheduled({ - payload: bookingRescheduledPayload, - actor: auditActor, - auditData: { - startTime: { - old: bookingRescheduledPayload.oldBooking?.startTime.toISOString() ?? null, - new: bookingRescheduledPayload.booking.startTime.toISOString(), - }, - endTime: { - old: bookingRescheduledPayload.oldBooking?.endTime.toISOString() ?? null, - new: bookingRescheduledPayload.booking.endTime.toISOString(), - }, - rescheduledToUid: { - old: null, - new: bookingRescheduledPayload.booking.uid, - }, - }, - source: actionSource, - operationId: null, - }); - } else { - await bookingEventHandler.onBookingCreated({ - payload: bookingCreatedPayload, - actor: auditActor, - auditData: { - startTime: bookingCreatedPayload.booking.startTime.getTime(), - endTime: bookingCreatedPayload.booking.endTime.getTime(), - status: bookingCreatedPayload.booking.status, - }, - source: actionSource, - operationId: null, - }); - } - const webhookData: EventPayloadType = { ...evt, ...eventTypeInfo, @@ -2569,6 +2556,12 @@ async function handler( paymentId: payment?.id, isDryRun, ...(isDryRun ? { troubleshooterData } : {}), + organizationId: eventOrganizationId, + previousBooking: originalRescheduledBooking ? { + uid: originalRescheduledBooking.uid, + startTime: originalRescheduledBooking.startTime, + endTime: originalRescheduledBooking.endTime, + } : null, }; } @@ -2811,6 +2804,12 @@ async function handler( references: referencesToCreate, seatReferenceUid: evt.attendeeSeatId, videoCallUrl: metadata?.videoCallUrl, + organizationId: eventOrganizationId, + previousBooking: originalRescheduledBooking ? { + uid: originalRescheduledBooking.uid, + startTime: originalRescheduledBooking.startTime, + endTime: originalRescheduledBooking.endTime, + } : null, }; } @@ -2822,12 +2821,97 @@ async function handler( export class RegularBookingService implements IBookingService { constructor(private readonly deps: IBookingServiceDependencies) { } + async fireBookingEvents({ + booking, + organizerUser, + hashedLink, + isDryRun, + eventOrganizationId, + bookerEmail, + fullName, + userUuid, + originalRescheduledBooking, + actionSource, + isRecurringBooking, + tracingLogger, + }: { + booking: { + id: number; + uid: string; + startTime: Date; + endTime: Date; + status: BookingStatus; + userId: number | null; + attendees?: Array<{ id: number; email: string }>; + }; + organizerUser: { id: number }; + hashedLink: string | null; + isDryRun: boolean; + eventOrganizationId: number | null; + bookerEmail: string; + fullName: string; + userUuid: string | null; + originalRescheduledBooking: BookingType | null; + actionSource: ActionSource; + isRecurringBooking: boolean; + tracingLogger: ReturnType; + }) { + const bookingCreatedPayload = buildBookingCreatedPayload({ + booking, + organizerUserId: organizerUser.id, + hashedLink, + isDryRun, + organizationId: eventOrganizationId, + }); + + const bookingEventHandler = this.deps.bookingEventHandler; + + const bookerAttendeeId = booking?.attendees?.find((attendee) => attendee.email === bookerEmail)?.id; + + const auditActor = getBookingAuditActorForNewBooking({ + attendeeId: bookerAttendeeId ?? null, + userUuid: userUuid ?? null, + bookerEmail, + bookerName: fullName, + 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, + }); + } else { + await bookingEventHandler.onBookingCreated({ + payload: bookingCreatedPayload, + actor: auditActor, + auditData: buildBookingCreatedAuditData({ booking }), + source: actionSource, + operationId: null, + }); + } + } + } + async createBooking(input: { bookingData: CreateRegularBookingData; bookingMeta?: CreateBookingMeta }) { - return handler({ bookingData: input.bookingData, ...input.bookingMeta }, this.deps); + return handler.bind(this)({ bookingData: input.bookingData, ...input.bookingMeta }, this.deps); } async rescheduleBooking(input: { bookingData: CreateRegularBookingData; bookingMeta?: CreateBookingMeta }) { - return handler({ bookingData: input.bookingData, ...input.bookingMeta }, this.deps); + return handler.bind(this)({ bookingData: input.bookingData, ...input.bookingMeta }, this.deps); } /** @@ -2839,7 +2923,7 @@ export class RegularBookingService implements IBookingService { bookingDataSchemaGetter: BookingDataSchemaGetter; }) { const bookingMeta = input.bookingMeta ?? {}; - return handler( + return handler.bind(this)( { bookingData: input.bookingData, ...bookingMeta, diff --git a/packages/features/bookings/repositories/AttendeeRepository.ts b/packages/features/bookings/repositories/AttendeeRepository.ts index 7230f415525182..26dd453793c179 100644 --- a/packages/features/bookings/repositories/AttendeeRepository.ts +++ b/packages/features/bookings/repositories/AttendeeRepository.ts @@ -2,11 +2,6 @@ import type { PrismaClient } from "@calcom/prisma"; import type { IAttendeeRepository } from "./IAttendeeRepository"; -/** - * Prisma-based implementation of IAttendeeRepository - * - * This repository provides methods for looking up attendee information. - */ export class AttendeeRepository implements IAttendeeRepository { constructor(private prismaClient: PrismaClient) {} diff --git a/packages/features/di/modules/Attendee.ts b/packages/features/di/modules/Attendee.ts new file mode 100644 index 00000000000000..9571f4cb4c48cb --- /dev/null +++ b/packages/features/di/modules/Attendee.ts @@ -0,0 +1,21 @@ +import { DI_TOKENS } from "@calcom/features/di/tokens"; +import { AttendeeRepository } from "@calcom/features/bookings/repositories/AttendeeRepository"; +import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma"; + +import { createModule, bindModuleToClassOnToken, type ModuleLoader } from "../di"; + +export const attendeeRepositoryModule = createModule(); +const token = DI_TOKENS.ATTENDEE_REPOSITORY; +const moduleToken = DI_TOKENS.ATTENDEE_REPOSITORY_MODULE; +const loadModule = bindModuleToClassOnToken({ + module: attendeeRepositoryModule, + moduleToken, + token, + classs: AttendeeRepository, + dep: prismaModuleLoader, +}); + +export const moduleLoader: ModuleLoader = { + token, + loadModule, +}; diff --git a/packages/features/ee/payments/api/webhook.ts b/packages/features/ee/payments/api/webhook.ts index ce4430d914969e..acb6bc99042647 100644 --- a/packages/features/ee/payments/api/webhook.ts +++ b/packages/features/ee/payments/api/webhook.ts @@ -3,6 +3,8 @@ import type { NextApiRequest, NextApiResponse } from "next"; import type Stripe from "stripe"; import { handlePaymentSuccess } from "@calcom/app-store/_utils/payments/handlePaymentSuccess"; +import { eventTypeAppMetadataOptionalSchema } from "@calcom/app-store/zod-utils"; +import { metadata as stripeMetadata } from "@calcom/app-store/stripepayment/_metadata"; import { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/app-store/zod-utils"; import { sendAttendeeRequestEmailAndSMS, sendOrganizerRequestEmail } from "@calcom/emails/email-manager"; import EventManager, { placeholderCreatedEvent } from "@calcom/features/bookings/lib/EventManager"; @@ -10,6 +12,7 @@ import { doesBookingRequireConfirmation } from "@calcom/features/bookings/lib/do import { getAllCredentialsIncludeServiceAccountKey } from "@calcom/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials"; import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation"; import { getBooking } from "@calcom/features/bookings/lib/payment/getBooking"; +import { makeAppActor, makeAppActorUsingSlug } from "@calcom/features/bookings/lib/types/actor"; import stripe from "@calcom/features/ee/payments/server/stripe"; import { getPlatformParams } from "@calcom/features/platform-oauth-client/get-platform-params"; import { PlatformOAuthClientRepository } from "@calcom/features/platform-oauth-client/platform-oauth-client.repository"; @@ -48,9 +51,13 @@ export async function handleStripePaymentSuccess(event: Stripe.Event, traceConte log.error("Stripe: Payment Not Found", safeStringify(paymentIntent), safeStringify(payment)); throw new HttpCode({ statusCode: 204, message: "Payment not found" }); } - if (!payment?.bookingId) throw new HttpCode({ statusCode: 204, message: "Payment not found" }); - await handlePaymentSuccess(payment.id, payment.bookingId, traceContext); + await handlePaymentSuccess({ + paymentId: payment.id, + bookingId: payment.bookingId, + appSlug: "stripe", + traceContext + }); } const handleSetupSuccess = async (event: Stripe.Event, traceContext: TraceContext) => { @@ -124,6 +131,18 @@ const handleSetupSuccess = async (event: Stripe.Event, traceContext: TraceContex // If the card information was already captured in the same customer. Delete the previous payment method if (!requiresConfirmation) { + // Extract credentialId from metadata + const apps = eventTypeAppMetadataOptionalSchema.parse(eventType?.metadata?.apps); + const credentialId = apps?.stripe?.credentialId; + + const actor = credentialId + ? makeAppActor({ credentialId }) + : makeAppActorUsingSlug({ appSlug: stripeMetadata.slug, name: stripeMetadata.name }); + + if (!credentialId) { + log.warn("Missing Stripe credentialId in event type metadata, using appSlug fallback"); + } + await handleConfirmation({ user: { ...user, credentials: allCredentials }, evt, @@ -132,6 +151,8 @@ const handleSetupSuccess = async (event: Stripe.Event, traceContext: TraceContex booking, paid: true, platformClientParams: platformOAuthClient ? getPlatformParams(platformOAuthClient) : undefined, + source: "WEBHOOK", + actor, traceContext: updatedTraceContext, }); } else if (areEmailsEnabled) { diff --git a/packages/features/ee/round-robin/roundRobinDeleteEvents.test.ts b/packages/features/ee/round-robin/roundRobinDeleteEvents.test.ts index c0142e296be18c..114cb1b25c03e5 100644 --- a/packages/features/ee/round-robin/roundRobinDeleteEvents.test.ts +++ b/packages/features/ee/round-robin/roundRobinDeleteEvents.test.ts @@ -116,6 +116,7 @@ describe("roundRobinReassignment test", () => { await roundRobinReassignment({ bookingId: 123, reassignedById: 101, + reassignedByUuid: "test-uuid-101", orgId: null, }); diff --git a/packages/features/ee/round-robin/roundRobinManualReassignment.ts b/packages/features/ee/round-robin/roundRobinManualReassignment.ts index 9d13c57c04e5fd..c1dc94154723d3 100644 --- a/packages/features/ee/round-robin/roundRobinManualReassignment.ts +++ b/packages/features/ee/round-robin/roundRobinManualReassignment.ts @@ -8,11 +8,16 @@ import { sendReassignedScheduledEmailsAndSMS, sendReassignedUpdatedEmailsAndSMS, } from "@calcom/emails/email-manager"; +import type { ReassignmentAuditData } from "@calcom/features/booking-audit/lib/actions/ReassignmentAuditActionService"; +import { getBookingEventHandlerService } from "@calcom/features/bookings/di/BookingEventHandlerService.container"; +import type { ActionSource } from "@calcom/features/booking-audit/lib/types/actionSource"; import EventManager from "@calcom/features/bookings/lib/EventManager"; import { getAllCredentialsIncludeServiceAccountKey } from "@calcom/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials"; import { getBookingResponsesPartialSchema } from "@calcom/features/bookings/lib/getBookingResponsesSchema"; import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses"; import { getEventTypesFromDB } from "@calcom/features/bookings/lib/handleNewBooking/getEventTypesFromDB"; +import { makeGuestActor, buildActorEmail } from "@calcom/features/booking-audit/lib/makeActor"; +import type { Actor } from "@calcom/features/booking-audit/lib/dto/types"; import { CreditService } from "@calcom/features/ee/billing/credit-service"; import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer"; import AssignmentReasonRecorder, { @@ -42,33 +47,66 @@ import type { BookingSelectResult } from "./utils/bookingSelect"; import { bookingSelect } from "./utils/bookingSelect"; import { getDestinationCalendar } from "./utils/getDestinationCalendar"; import { getTeamMembers } from "./utils/getTeamMembers"; +import { type ISimpleLogger } from "@calcom/features/di/shared/services/logger.service"; +import { safeStringify } from "@calcom/lib/safeStringify"; enum ErrorCode { InvalidRoundRobinHost = "invalid_round_robin_host", UserIsFixed = "user_is_round_robin_fixed", } +function getAuditActor({ reassignedByUuid, bookingUid }: { + reassignedByUuid: string | null; + bookingUid: string; + logger: ISimpleLogger; +}): Actor { + if (!reassignedByUuid) { + logger.warn("No reassignedByUuid provided, creating fallback guest actor for audit log", safeStringify({ + bookingUid, + })); + } + return makeGuestActor({ email: buildActorEmail({ identifier: `fallback-${bookingUid}-${Date.now()}`, actorType: "guest" }), name: null }); +} + export const roundRobinManualReassignment = async ({ bookingId, newUserId, orgId, reassignReason, reassignedById, + reassignedByUuid, emailsEnabled = true, platformClientParams, + actionSource = "UNKNOWN", }: { bookingId: number; newUserId: number; orgId: number | null; reassignReason?: string; reassignedById: number; + /** + * The UUID of the user performing the reassignment. + * Used for audit logging to track who reassigned the booking. + * If not provided, audit log is skipped (reassignment requires auth, so this shouldn't happen). + */ + reassignedByUuid?: string; emailsEnabled?: boolean; platformClientParams?: PlatformClientParams; + actionSource?: ActionSource; }) => { const roundRobinReassignLogger = logger.getSubLogger({ prefix: ["roundRobinManualReassign", `${bookingId}`], }); + if (actionSource === "UNKNOWN") { + roundRobinReassignLogger.warn("Round robin manual reassignment called with unknown actionSource", { + bookingId, + newUserId, + reassignedById, + reassignedByUuid, + }); + } + roundRobinReassignLogger.info(`User ${reassignedById} initiating manual reassignment to user ${newUserId}`); let booking = await prisma.booking.findUnique({ @@ -103,14 +141,14 @@ export const roundRobinManualReassignment = async ({ const eventTypeHosts = eventType.hosts.length ? eventType.hosts : eventType.users.map((user) => ({ - user, - isFixed: false, - priority: 2, - weight: 100, - schedule: null, - createdAt: new Date(0), // use earliest possible date as fallback - groupId: null, - })); + user, + isFixed: false, + priority: 2, + weight: 100, + schedule: null, + createdAt: new Date(0), // use earliest possible date as fallback + groupId: null, + })); const fixedHost = eventTypeHosts.find((host) => host.isFixed); const currentRRHost = booking.attendees.find((attendee) => @@ -200,6 +238,10 @@ export const roundRobinManualReassignment = async ({ t: newUserT, }); + const oldUserId = booking.userId; + const oldEmail = booking.user?.email; + const oldTitle = booking.title; + booking = await prisma.booking.update({ where: { id: bookingId }, data: { @@ -212,12 +254,36 @@ export const roundRobinManualReassignment = async ({ startTime: booking.startTime, endTime: booking.endTime, userId: newUser.id, - reassignedById, + reassignedById: reassignedById, }), }, select: bookingSelect, }); + const bookingEventHandlerService = getBookingEventHandlerService(); + const auditData: ReassignmentAuditData = { + assignedToId: { old: oldUserId ?? null, new: newUserId }, + assignedById: { old: null, new: reassignedById }, + reassignmentReason: { old: null, new: reassignReason ?? null }, + reassignmentType: "manual", + userPrimaryEmail: { old: oldEmail || null, new: newUser.email }, + title: { old: oldTitle, new: newBookingTitle }, + }; + + const actor = getAuditActor({ + reassignedByUuid: reassignedByUuid ?? null, + bookingUid: booking.uid, + logger: roundRobinReassignLogger, + }); + + await bookingEventHandlerService.onReassignment({ + bookingUid: booking.uid, + actor, + organizationId: orgId, + auditData, + source: actionSource, + }); + await AssignmentReasonRecorder.roundRobinReassignment({ bookingId, reassignReason, @@ -334,8 +400,8 @@ export const roundRobinManualReassignment = async ({ const previousHostDestinationCalendar = hasOrganizerChanged ? await prisma.destinationCalendar.findFirst({ - where: { userId: originalOrganizer.id }, - }) + where: { userId: originalOrganizer.id }, + }) : null; const apps = eventTypeAppMetadataOptionalSchema.parse(eventType?.metadata?.apps); @@ -577,22 +643,22 @@ export async function handleWorkflowsUpdate({ }, ...(eventType?.teamId ? [ - { - activeOnTeams: { - some: { - teamId: eventType.teamId, - }, + { + activeOnTeams: { + some: { + teamId: eventType.teamId, }, }, - ] + }, + ] : []), ...(eventType?.team?.parentId ? [ - { - isActiveOnAll: true, - teamId: eventType.team.parentId, - }, - ] + { + isActiveOnAll: true, + teamId: eventType.team.parentId, + }, + ] : []), ], }, diff --git a/packages/features/ee/round-robin/roundRobinReassignment.test.ts b/packages/features/ee/round-robin/roundRobinReassignment.test.ts index d717da191cb2a5..95deb8a38d55cd 100644 --- a/packages/features/ee/round-robin/roundRobinReassignment.test.ts +++ b/packages/features/ee/round-robin/roundRobinReassignment.test.ts @@ -164,6 +164,9 @@ describe("roundRobinReassignment test", () => { await roundRobinReassignment({ bookingId: 123, + orgId: null, + reassignedById: 1, + reassignedByUuid: "test-uuid-1", }); expect(eventManagerSpy).toBeCalledTimes(1); @@ -276,6 +279,9 @@ describe("roundRobinReassignment test", () => { await roundRobinReassignment({ bookingId: 123, + orgId: null, + reassignedById: 1, + reassignedByUuid: "test-uuid-1", }); expect(eventManagerSpy).toBeCalledTimes(1); diff --git a/packages/features/ee/round-robin/roundRobinReassignment.ts b/packages/features/ee/round-robin/roundRobinReassignment.ts index d25acf9924545c..cc9d13d9a7be43 100644 --- a/packages/features/ee/round-robin/roundRobinReassignment.ts +++ b/packages/features/ee/round-robin/roundRobinReassignment.ts @@ -12,6 +12,7 @@ import { sendReassignedScheduledEmailsAndSMS, sendReassignedUpdatedEmailsAndSMS, } from "@calcom/emails/email-manager"; +import { getBookingEventHandlerService } from "@calcom/features/bookings/di/BookingEventHandlerService.container"; import EventManager from "@calcom/features/bookings/lib/EventManager"; import { getAllCredentialsIncludeServiceAccountKey } from "@calcom/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials"; import { getBookingResponsesPartialSchema } from "@calcom/features/bookings/lib/getBookingResponsesSchema"; @@ -19,7 +20,9 @@ import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventR import { ensureAvailableUsers } from "@calcom/features/bookings/lib/handleNewBooking/ensureAvailableUsers"; import { getEventTypesFromDB } from "@calcom/features/bookings/lib/handleNewBooking/getEventTypesFromDB"; import type { IsFixedAwareUser } from "@calcom/features/bookings/lib/handleNewBooking/types"; +import { makeUserActor } from "@calcom/features/booking-audit/lib/makeActor"; import { getLuckyUserService } from "@calcom/features/di/containers/LuckyUser"; +import type { ActionSource } from "@calcom/features/booking-audit/lib/types/actionSource"; import AssignmentReasonRecorder, { RRReassignmentType, } from "@calcom/features/ee/round-robin/assignmentReason/AssignmentReasonRecorder"; @@ -47,17 +50,29 @@ export const roundRobinReassignment = async ({ emailsEnabled = true, platformClientParams, reassignedById, + reassignedByUuid, + actionSource = "UNKNOWN", }: { bookingId: number; orgId: number | null; emailsEnabled?: boolean; platformClientParams?: PlatformClientParams; reassignedById: number; + reassignedByUuid: string; + actionSource?: ActionSource; }) => { const roundRobinReassignLogger = logger.getSubLogger({ prefix: ["roundRobinReassign", `${bookingId}`], }); + if (actionSource === "UNKNOWN") { + roundRobinReassignLogger.warn("Round robin reassignment called with unknown actionSource", { + bookingId, + reassignedById, + reassignedByUuid, + }); + } + roundRobinReassignLogger.info(`User ${reassignedById} initiating round robin reassignment`); let booking = await prisma.booking.findUnique({ @@ -101,14 +116,14 @@ export const roundRobinReassignment = async ({ eventType.hosts = eventType.hosts.length ? eventType.hosts : eventType.users.map((user) => ({ - user, - isFixed: false, - priority: 2, - weight: 100, - schedule: null, - createdAt: new Date(0), // use earliest possible date as fallback - groupId: null, - })); + user, + isFixed: false, + priority: 2, + weight: 100, + schedule: null, + createdAt: new Date(0), // use earliest possible date as fallback + groupId: null, + })); if (eventType.hosts.length === 0) { throw new Error(ErrorCode.EventTypeNoHosts); @@ -246,6 +261,10 @@ export const roundRobinReassignment = async ({ newBookingTitle = getEventName(eventNameObject); + const oldUserId = booking.userId; + const oldEmail = booking.user?.email || ""; + const oldTitle = booking.title; + booking = await prisma.booking.update({ where: { id: bookingId, @@ -259,11 +278,27 @@ export const roundRobinReassignment = async ({ startTime: booking.startTime, endTime: booking.endTime, userId: reassignedRRHost.id, - reassignedById, + reassignedById: reassignedById, }), }, select: bookingSelect, }); + + const bookingEventHandlerService = getBookingEventHandlerService(); + await bookingEventHandlerService.onReassignment({ + bookingUid: booking.uid, + actor: makeUserActor(reassignedByUuid), + organizationId: orgId, + auditData: { + assignedToId: { old: oldUserId, new: reassignedRRHost.id }, + assignedById: { old: null, new: reassignedById }, + reassignmentReason: { old: null, new: "Round robin reassignment" }, + reassignmentType: "roundRobin", + userPrimaryEmail: { old: oldEmail, new: reassignedRRHost.email }, + title: { old: oldTitle, new: newBookingTitle }, + }, + source: actionSource, + }); } else { const previousRRHostAttendee = booking.attendees.find( (attendee) => attendee.email === previousRRHost.email @@ -299,10 +334,10 @@ export const roundRobinReassignment = async ({ // If changed owner, also change destination calendar const previousHostDestinationCalendar = hasOrganizerChanged ? await prisma.destinationCalendar.findFirst({ - where: { - userId: originalOrganizer.id, - }, - }) + where: { + userId: originalOrganizer.id, + }, + }) : null; const evt: CalendarEvent = { @@ -349,6 +384,7 @@ export const roundRobinReassignment = async ({ // To prevent "The requested identifier already exists" error while updating event, we need to remove iCalUID evt.iCalUID = undefined; } + const credentials = await prisma.credential.findMany({ where: { userId: organizer.id, diff --git a/packages/features/handleMarkNoShow.ts b/packages/features/handleMarkNoShow.ts index 09634b07fa85b3..e9c413d21793d5 100644 --- a/packages/features/handleMarkNoShow.ts +++ b/packages/features/handleMarkNoShow.ts @@ -1,5 +1,8 @@ import { type TFunction } from "i18next"; +import { getBookingEventHandlerService } from "@calcom/features/bookings/di/BookingEventHandlerService.container"; +import type { Actor } from "@calcom/features/booking-audit/lib/dto/types"; +import type { ActionSource } from "@calcom/features/booking-audit/lib/types/actionSource"; import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; import { BookingAccessService } from "@calcom/features/bookings/services/BookingAccessService"; import { CreditService } from "@calcom/features/ee/billing/credit-service"; @@ -18,7 +21,7 @@ import { prisma } from "@calcom/prisma"; import { WebhookTriggerEvents, WorkflowTriggerEvents } from "@calcom/prisma/enums"; import { bookingMetadataSchema, type PlatformClientParams } from "@calcom/prisma/zod-utils"; import type { TNoShowInputSchema } from "@calcom/trpc/server/routers/loggedInViewer/markNoShow.schema"; - +import { makeGuestActor, makeUserActor } from "@calcom/features/booking-audit/lib/makeActor"; import handleSendingAttendeeNoShowDataToApps from "./noShow/handleSendingAttendeeNoShowDataToApps"; export type NoShowAttendees = { email: string; noShow: boolean }[]; @@ -90,24 +93,63 @@ const handleMarkNoShow = async ({ attendees, noShowHost, userId, - userUuid: _userUuid, + userUuid, locale, platformClientParams, + actionSource = "UNKNOWN", }: TNoShowInputSchema & { + /** + * @deprecated Use userUuid instead + */ userId?: number; userUuid?: string; locale?: string; platformClientParams?: PlatformClientParams; + actionSource?: ActionSource; }) => { + if (actionSource === "UNKNOWN") { + logger.warn("Mark no-show called with unknown actionSource", { + bookingUid, + userId, + userUuid, + }); + } const responsePayload = new ResponsePayload(); const t = await getTranslation(locale ?? "en", "common"); + // Helper function to get the appropriate actor + const getAuditActor = (): Actor => { + // Prefer user actor when userUuid is available (authenticated action) + if (userUuid) { + return makeUserActor(userUuid); + } + + // Fall back to guest actor for unauthenticated actions + logger.warn("No actor identifier available for mark no-show audit, creating fallback guest actor", { + bookingUid, + }); + const fallbackEmail = `fallback-${bookingUid}-${Date.now()}@guest.internal`; + return makeGuestActor({ email: fallbackEmail, name: null }); + }; + try { const attendeeEmails = attendees?.map((attendee) => attendee.email) || []; if (attendees && attendeeEmails.length > 0) { await assertCanAccessBooking(bookingUid, userId); + // Get old noShow values before updating for audit log + const oldAttendeeValues = await prisma.attendee.findMany({ + where: { + booking: { uid: bookingUid }, + email: { in: attendeeEmails }, + }, + select: { + email: true, + noShow: true, + }, + }); + const payload = await buildResultPayload(bookingUid, attendeeEmails, attendees, t); const { webhooks, bookingId } = await getWebhooksService( @@ -251,14 +293,14 @@ const handleMarkNoShow = async ({ const destinationCalendar = booking.destinationCalendar ? [booking.destinationCalendar] : booking.user?.destinationCalendar - ? [booking.user?.destinationCalendar] - : []; + ? [booking.user?.destinationCalendar] + : []; const team = booking.eventType?.team ? { - name: booking.eventType.team.name, - id: booking.eventType.team.id, - members: [], - } + name: booking.eventType.team.name, + id: booking.eventType.team.id, + members: [], + } : undefined; const calendarEvent: ExtendedCalendarEvent = { @@ -316,10 +358,54 @@ const handleMarkNoShow = async ({ responsePayload.setAttendees(payload.attendees); responsePayload.setMessage(payload.message); + // Create audit log for attendee no-show updates + if (payload.attendees.length > 0) { + const bookingForAudit = await prisma.booking.findUnique({ + where: { uid: bookingUid }, + select: { + id: true, + eventType: { + select: { + userId: true, + teamId: true, + }, + }, + }, + }); + + if (bookingForAudit) { + const actor = getAuditActor(); + const bookingEventHandlerService = getBookingEventHandlerService(); + const orgId = await getOrgIdFromMemberOrTeamId({ + memberId: bookingForAudit.eventType?.userId ?? null, + teamId: bookingForAudit.eventType?.teamId ?? null, + }); + + // Track if any attendee was marked as no-show + const anyOldNoShow = oldAttendeeValues.some((a) => a.noShow); + const anyNewNoShow = payload.attendees.some((a) => a.noShow); + + await bookingEventHandlerService.onAttendeeNoShowUpdated({ + bookingUid, + actor, + organizationId: orgId ?? null, + auditData: { + noShowAttendee: { old: anyOldNoShow, new: anyNewNoShow }, + }, + source: actionSource, + }); + } + } + await handleSendingAttendeeNoShowDataToApps(bookingUid, attendees); } if (noShowHost) { + const bookingToUpdate = await prisma.booking.findUnique({ + where: { uid: bookingUid }, + select: { id: true, noShowHost: true }, + }); + await prisma.booking.update({ where: { uid: bookingUid, @@ -329,6 +415,42 @@ const handleMarkNoShow = async ({ }, }); + if (bookingToUpdate) { + const bookingForAudit = await prisma.booking.findUnique({ + where: { uid: bookingUid }, + select: { + id: true, + eventType: { + select: { + userId: true, + teamId: true, + }, + }, + }, + }); + + if (bookingForAudit) { + const actor = getAuditActor(); + const bookingEventHandlerService = getBookingEventHandlerService(); + const orgId = await getOrgIdFromMemberOrTeamId({ + memberId: bookingForAudit.eventType?.userId ?? null, + teamId: bookingForAudit.eventType?.teamId ?? null, + }); + await bookingEventHandlerService.onHostNoShowUpdated({ + bookingUid, + actor, + organizationId: orgId ?? null, + auditData: { + noShowHost: { + old: bookingToUpdate.noShowHost, + new: true, + }, + }, + source: actionSource, + }); + } + } + responsePayload.setNoShowHost(true); responsePayload.setMessage(t("booking_no_show_updated")); } diff --git a/packages/features/pbac/domain/types/permission-registry.ts b/packages/features/pbac/domain/types/permission-registry.ts index 23e67ecf003b32..3e69c8a4bd71b2 100644 --- a/packages/features/pbac/domain/types/permission-registry.ts +++ b/packages/features/pbac/domain/types/permission-registry.ts @@ -33,10 +33,10 @@ export enum CustomAction { ReadTeamBookings = "readTeamBookings", ReadOrgBookings = "readOrgBookings", ReadRecordings = "readRecordings", - Impersonate = "impersonate", - EditUsers = "editUsers", ReadTeamAuditLogs = "readTeamAuditLogs", ReadOrgAuditLogs = "readOrgAuditLogs", + Impersonate = "impersonate", + EditUsers = "editUsers", } export enum Scope { @@ -477,18 +477,11 @@ export const PERMISSION_REGISTRY: PermissionRegistry = { descriptionI18nKey: "pbac_desc_view_booking_recordings", dependsOn: ["booking.read"], }, - [CrudAction.Update]: { - description: "Update bookings", - category: "booking", - i18nKey: "pbac_action_update", - descriptionI18nKey: "pbac_desc_update_bookings", - dependsOn: ["booking.read"], - }, [CustomAction.ReadTeamAuditLogs]: { description: "View team booking audit logs", category: "booking", i18nKey: "pbac_action_read_team_audit_logs", - descriptionI18nKey: "pbac_desc_view_team_booking_audit_logs", + descriptionI18nKey: "pbac_desc_view_team_audit_logs", scope: [Scope.Team], dependsOn: ["booking.read"], }, @@ -496,10 +489,17 @@ export const PERMISSION_REGISTRY: PermissionRegistry = { description: "View organization booking audit logs", category: "booking", i18nKey: "pbac_action_read_org_audit_logs", - descriptionI18nKey: "pbac_desc_view_organization_booking_audit_logs", + descriptionI18nKey: "pbac_desc_view_org_audit_logs", scope: [Scope.Organization], dependsOn: ["booking.read"], }, + [CrudAction.Update]: { + description: "Update bookings", + category: "booking", + i18nKey: "pbac_action_update", + descriptionI18nKey: "pbac_desc_update_bookings", + dependsOn: ["booking.read"], + }, }, [Resource.Insights]: { _resource: { diff --git a/packages/features/users/repositories/UserRepository.ts b/packages/features/users/repositories/UserRepository.ts index e37b19e170832b..f79993be7ca65b 100644 --- a/packages/features/users/repositories/UserRepository.ts +++ b/packages/features/users/repositories/UserRepository.ts @@ -985,6 +985,7 @@ export class UserRepository { }, }); } + async getTimeZoneAndDefaultScheduleId({ userId }: { userId: number }) { return await this.prismaClient.user.findUnique({ where: { diff --git a/packages/platform/libraries/package.json b/packages/platform/libraries/package.json index 85cd7cb936d34c..2785b17b57f9a3 100644 --- a/packages/platform/libraries/package.json +++ b/packages/platform/libraries/package.json @@ -1,6 +1,6 @@ { "name": "@calcom/platform-libraries", - "version": "0.0.0", + "version": "9.9.9", "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/platform/libraries/repositories.ts b/packages/platform/libraries/repositories.ts index 8b3107162a0cc4..b9df51d24c15f8 100644 --- a/packages/platform/libraries/repositories.ts +++ b/packages/platform/libraries/repositories.ts @@ -10,6 +10,9 @@ export { TeamRepository as PrismaTeamRepository } from "@calcom/features/ee/team export { UserRepository as PrismaUserRepository } from "@calcom/features/users/repositories/UserRepository"; export { FeaturesRepository as PrismaFeaturesRepository } from "@calcom/features/flags/features.repository"; export { MembershipRepository as PrismaMembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository"; +export { HostRepository as PrismaHostRepository } from "@calcom/lib/server/repository/host"; +export type { IAuditActorRepository } from "@calcom/features/booking-audit/lib/repository/IAuditActorRepository"; +export { PrismaAuditActorRepository } from "@calcom/features/booking-audit/lib/repository/PrismaAuditActorRepository"; export { HostRepository as PrismaHostRepository } from "@calcom/features/host/repositories/HostRepository"; export { AccessCodeRepository as PrismaAccessCodeRepository } from "@calcom/features/oauth/repositories/AccessCodeRepository"; export { OAuthClientRepository as PrismaOAuthClientRepository } from "@calcom/features/oauth/repositories/OAuthClientRepository"; diff --git a/packages/trpc/server/middlewares/sessionMiddleware.ts b/packages/trpc/server/middlewares/sessionMiddleware.ts index ab8937ba178763..bc917fef46dbb9 100644 --- a/packages/trpc/server/middlewares/sessionMiddleware.ts +++ b/packages/trpc/server/middlewares/sessionMiddleware.ts @@ -45,10 +45,10 @@ export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe>; diff --git a/packages/trpc/server/routers/loggedInViewer/markNoShow.handler.ts b/packages/trpc/server/routers/loggedInViewer/markNoShow.handler.ts index e2dda27bdc64ce..e0e79b646e5db6 100644 --- a/packages/trpc/server/routers/loggedInViewer/markNoShow.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/markNoShow.handler.ts @@ -18,6 +18,7 @@ export const markNoShow = async ({ ctx, input }: NoShowOptions) => { attendees, noShowHost, userId: ctx.user.id, + userUuid: ctx.user.uuid, locale: ctx.user.locale, }); }; diff --git a/packages/trpc/server/routers/publicViewer/markHostAsNoShow.handler.ts b/packages/trpc/server/routers/publicViewer/markHostAsNoShow.handler.ts index f6da15cbee7f9c..7822651ec6fab1 100644 --- a/packages/trpc/server/routers/publicViewer/markHostAsNoShow.handler.ts +++ b/packages/trpc/server/routers/publicViewer/markHostAsNoShow.handler.ts @@ -9,7 +9,10 @@ type NoShowOptions = { export const noShowHandler = async ({ input }: NoShowOptions) => { const { bookingUid, noShowHost } = input; - return handleMarkNoShow({ bookingUid, noShowHost }); + return handleMarkNoShow({ + bookingUid, + noShowHost, + }); }; export default noShowHandler; diff --git a/packages/trpc/server/routers/viewer/bookings/_router.tsx b/packages/trpc/server/routers/viewer/bookings/_router.tsx index 6d7f5fee855f2f..8ef8c4efa2b416 100644 --- a/packages/trpc/server/routers/viewer/bookings/_router.tsx +++ b/packages/trpc/server/routers/viewer/bookings/_router.tsx @@ -51,6 +51,7 @@ export const bookingsRouter = router({ return addGuestsHandler({ ctx, input, + actionSource: "WEBAPP", }); }), diff --git a/packages/trpc/server/routers/viewer/bookings/addGuests.handler.ts b/packages/trpc/server/routers/viewer/bookings/addGuests.handler.ts index 447a253d74f220..52cbd6d77954bc 100644 --- a/packages/trpc/server/routers/viewer/bookings/addGuests.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/addGuests.handler.ts @@ -1,8 +1,11 @@ import { getUsersCredentialsIncludeServiceAccountKey } from "@calcom/app-store/delegationCredential"; import { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/app-store/zod-utils"; import dayjs from "@calcom/dayjs"; +import { getBookingEventHandlerService } from "@calcom/features/bookings/di/BookingEventHandlerService.container"; import { BookingEmailSmsHandler } from "@calcom/features/bookings/lib/BookingEmailSmsHandler"; import EventManager from "@calcom/features/bookings/lib/EventManager"; +import { makeUserActor } from "@calcom/features/booking-audit/lib/makeActor"; +import type { ActionSource } from "@calcom/features/booking-audit/lib/types/actionSource"; import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; import { UserRepository } from "@calcom/features/users/repositories/UserRepository"; @@ -21,7 +24,7 @@ import { TRPCError } from "@trpc/server"; import type { TrpcSessionUser } from "../../../types"; import type { TAddGuestsInputSchema } from "./addGuests.schema"; -type TUser = Pick, "id" | "email" | "organizationId"> & +type TUser = Pick, "id" | "email" | "organizationId" | "uuid"> & Partial, "profile">>; type AddGuestsOptions = { @@ -30,15 +33,24 @@ type AddGuestsOptions = { }; input: TAddGuestsInputSchema; emailsEnabled?: boolean; + actionSource?: ActionSource; }; type Booking = NonNullable>>; type OrganizerData = Awaited>; -export const addGuestsHandler = async ({ ctx, input, emailsEnabled = true }: AddGuestsOptions) => { +export const addGuestsHandler = async ({ ctx, input, emailsEnabled = true, actionSource = "UNKNOWN" }: AddGuestsOptions) => { const { user } = ctx; const { bookingId, guests } = input; + if (actionSource === "UNKNOWN") { + logger.warn("Add guests called with unknown actionSource", { + bookingId, + userId: user.id, + userUuid: user.uuid, + }); + } + const booking = await getBooking(bookingId); await validateUserPermissions(booking, user); @@ -75,6 +87,22 @@ export const addGuestsHandler = async ({ ctx, input, emailsEnabled = true }: Add await sendGuestNotifications(evt, booking, uniqueGuestEmails); } + // Create audit log for attendee addition + const bookingEventHandlerService = getBookingEventHandlerService(); + const organizationId = booking.user?.profiles?.[0]?.organizationId ?? user.organizationId ?? null; + await bookingEventHandlerService.onAttendeeAdded({ + bookingUid: booking.uid, + actor: makeUserActor(user.uuid), + organizationId, + auditData: { + attendees: { + old: booking.attendees.map((a) => a.email), + new: [...booking.attendees.map((a) => a.email), ...uniqueGuestEmails], + }, + }, + source: actionSource, + }); + return { message: "Guests added" }; }; @@ -286,8 +314,8 @@ async function buildCalendarEvent( destinationCalendar: booking?.destinationCalendar ? [booking?.destinationCalendar] : booking?.user?.destinationCalendar - ? [booking?.user?.destinationCalendar] - : [], + ? [booking?.user?.destinationCalendar] + : [], seatsPerTimeSlot: booking.eventType?.seatsPerTimeSlot, seatsShowAttendees: booking.eventType?.seatsShowAttendees, customReplyToEmail: booking.eventType?.customReplyToEmail, diff --git a/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts b/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts index c4850356efa1e5..fefc95d1b3ae08 100644 --- a/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts @@ -2,11 +2,13 @@ import { getUsersCredentialsIncludeServiceAccountKey } from "@calcom/app-store/d import type { LocationObject } from "@calcom/app-store/locations"; import { getLocationValueForDB } from "@calcom/app-store/locations"; import { sendDeclinedEmailsAndSMS } from "@calcom/emails/email-manager"; +import { getBookingEventHandlerService } from "@calcom/features/bookings/di/BookingEventHandlerService.container"; import { getAllCredentialsIncludeServiceAccountKey } from "@calcom/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials"; import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses"; import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation"; import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhookTrigger"; import { processPaymentRefund } from "@calcom/features/bookings/lib/payment/processPaymentRefund"; +import { makeUserActor } from "@calcom/features/booking-audit/lib/makeActor"; import { BookingAccessService } from "@calcom/features/bookings/services/BookingAccessService"; import { CreditService } from "@calcom/features/ee/billing/credit-service"; import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer"; @@ -28,6 +30,7 @@ import { BookingStatus, WebhookTriggerEvents, WorkflowTriggerEvents } from "@cal import type { EventTypeMetadata } from "@calcom/prisma/zod-utils"; import type { CalendarEvent } from "@calcom/types/Calendar"; +import { v4 as uuidv4 } from "uuid"; import { TRPCError } from "@trpc/server"; import type { TrpcSessionUser } from "../../../types"; @@ -125,6 +128,7 @@ export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => { name: true, destinationCalendar: true, locale: true, + uuid: true, }, }, id: true, @@ -173,6 +177,32 @@ export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => { }, }); + // Audit the booking acceptance for payment confirmation + const bookingEventHandlerService = getBookingEventHandlerService(); + const paymentConfirmTeamId = await getTeamIdFromEventType({ + eventType: { + team: { id: booking.eventType?.teamId ?? null }, + parentId: booking.eventType?.parentId ?? null, + }, + }); + const paymentConfirmOrgId = await getOrgIdFromMemberOrTeamId({ + memberId: booking.userId, + teamId: paymentConfirmTeamId, + }); + await bookingEventHandlerService.onBookingAccepted({ + bookingUid: booking.uid, + actor: makeUserActor(ctx.user.uuid), + organizationId: paymentConfirmOrgId ?? null, + operationId: null, + auditData: { + status: { + old: booking.status, + new: BookingStatus.ACCEPTED, + }, + }, + source: "WEBAPP", + }); + return { message: "Booking confirmed", status: BookingStatus.ACCEPTED }; } @@ -241,8 +271,8 @@ export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => { destinationCalendar: booking.destinationCalendar ? [booking.destinationCalendar] : booking.user?.destinationCalendar - ? [booking.user?.destinationCalendar] - : [], + ? [booking.user?.destinationCalendar] + : [], requiresConfirmation: booking?.eventType?.requiresConfirmation ?? false, hideOrganizerEmail: booking.eventType?.hideOrganizerEmail, hideCalendarNotes: booking.eventType?.hideCalendarNotes, @@ -251,10 +281,10 @@ export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => { customReplyToEmail: booking.eventType?.customReplyToEmail, team: booking.eventType?.team ? { - name: booking.eventType.team.name, - id: booking.eventType.team.id, - members: [], - } + name: booking.eventType.team.name, + id: booking.eventType.team.id, + members: [], + } : undefined, ...(platformClientParams ? platformClientParams : {}), organizationId: organizerOrganizationId ?? booking.eventType?.team?.parentId ?? null, @@ -330,6 +360,8 @@ export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => { booking, emailsEnabled, platformClientParams, + source: "WEBAPP", + actor: makeUserActor(ctx.user.uuid), traceContext, }); } else { @@ -381,6 +413,63 @@ export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => { const orgId = await getOrgIdFromMemberOrTeamId({ memberId: booking.userId, teamId }); + // Audit the booking rejection + const bookingEventHandlerService = getBookingEventHandlerService(); + if (recurringEventId) { + // For recurring bookings, fetch all affected bookings and audit each one + const rejectedBookings = await prisma.booking.findMany({ + where: { + recurringEventId, + status: BookingStatus.REJECTED, + }, + select: { + uid: true, + rejectionReason: true, + }, + }); + const operationId = uuidv4(); + await Promise.all( + rejectedBookings.map((rejectedBooking) => + bookingEventHandlerService.onBookingRejected({ + bookingUid: rejectedBooking.uid, + actor: makeUserActor(ctx.user.uuid), + organizationId: orgId ?? null, + operationId, + auditData: { + rejectionReason: { + old: null, + new: rejectedBooking.rejectionReason ?? null, + }, + status: { + old: BookingStatus.PENDING, + new: BookingStatus.REJECTED, + }, + }, + source: "WEBAPP", + }) + ) + ); + } else { + // For single booking rejection + await bookingEventHandlerService.onBookingRejected({ + bookingUid: booking.uid, + actor: makeUserActor(ctx.user.uuid), + organizationId: orgId ?? null, + operationId: null, + auditData: { + rejectionReason: { + old: null, + new: rejectionReason ?? null, + }, + status: { + old: booking.status, + new: BookingStatus.REJECTED, + }, + }, + source: "WEBAPP", + }); + } + // send BOOKING_REJECTED webhooks const subscriberOptions: GetSubscriberOptions = { userId: booking.userId, diff --git a/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts b/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts index db81d31e78fd89..f6556400386fc8 100644 --- a/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts @@ -5,6 +5,7 @@ import { getEventLocationType, OrganizerDefaultConferencingAppType } from "@calc import { getAppFromSlug } from "@calcom/app-store/utils"; import { sendLocationChangeEmailsAndSMS } from "@calcom/emails/email-manager"; import EventManager from "@calcom/features/bookings/lib/EventManager"; +import { makeUserActor } from "@calcom/features/booking-audit/lib/makeActor"; import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository"; import { CredentialAccessService } from "@calcom/features/credentials/services/CredentialAccessService"; @@ -24,6 +25,8 @@ import type { Ensure } from "@calcom/types/utils"; import { TRPCError } from "@trpc/server"; +import { getBookingEventHandlerService } from "@calcom/features/bookings/di/BookingEventHandlerService.container"; + import type { TrpcSessionUser } from "../../../types"; import type { TEditLocationInputSchema } from "./editLocation.schema"; import type { BookingsProcedureContext } from "./util"; @@ -261,6 +264,8 @@ export async function editLocationHandler({ ctx, input }: 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; @@ -310,5 +315,20 @@ export async function editLocationHandler({ ctx, input }: EditLocationOptions) { logger.error("Error sending LocationChangeEmails", safeStringify(error)); } + // Create audit log for location change + const bookingEventHandlerService = getBookingEventHandlerService(); + await bookingEventHandlerService.onLocationChanged({ + bookingUid: booking.uid, + actor: makeUserActor(ctx.user.uuid), + organizationId, + auditData: { + location: { + old: oldLocation, + new: newLocationInEvtFormat, + }, + }, + source: "WEBAPP", + }); + return { message: "Location updated" }; } diff --git a/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts b/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts index 7ee5bd3fbd097b..1b5da614868dd5 100644 --- a/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts @@ -5,6 +5,7 @@ import { getDelegationCredentialOrRegularCredential } from "@calcom/app-store/de import { getUsersCredentialsIncludeServiceAccountKey } from "@calcom/app-store/delegationCredential"; import dayjs from "@calcom/dayjs"; import { sendRequestRescheduleEmailAndSMS } from "@calcom/emails/email-manager"; +import type { RescheduleRequestedAuditData } from "@calcom/features/booking-audit/lib/actions/RescheduleRequestedAuditActionService"; import { makeUserActor } from "@calcom/features/booking-audit/lib/makeActor"; import type { ActionSource } from "@calcom/features/booking-audit/lib/types/actionSource"; import { getBookingEventHandlerService } from "@calcom/features/bookings/di/BookingEventHandlerService.container"; @@ -115,6 +116,28 @@ export const requestRescheduleHandler = async ({ ctx, input, source }: RequestRe cancelledBy: user.email, }); + const bookingEventHandlerService = getBookingEventHandlerService(); + const auditTeamId = await getTeamIdFromEventType({ + eventType: { + team: { id: bookingToReschedule.eventType?.teamId ?? null }, + parentId: bookingToReschedule.eventType?.parentId ?? null, + }, + }); + const auditTriggerForUser = !auditTeamId || (auditTeamId && bookingToReschedule.eventType?.parentId); + const auditUserId = auditTriggerForUser ? bookingToReschedule.userId : null; + const auditOrgId = await getOrgIdFromMemberOrTeamId({ memberId: auditUserId, teamId: auditTeamId }); + const auditData: RescheduleRequestedAuditData = { + rescheduleReason: cancellationReason ?? null, + rescheduledRequestedBy: user.email, + }; + await bookingEventHandlerService.onRescheduleRequested({ + bookingUid: bookingToReschedule.uid, + actor: makeUserActor(user.uuid), + organizationId: auditOrgId ?? null, + auditData, + source: "WEBAPP", + }); + // delete scheduled jobs of previous booking const webhookPromises = []; webhookPromises.push(deleteWebhookScheduledTriggers({ booking: bookingToReschedule })); @@ -171,10 +194,10 @@ export const requestRescheduleHandler = async ({ ctx, input, source }: RequestRe customReplyToEmail: bookingToReschedule.eventType?.customReplyToEmail, team: bookingToReschedule.eventType?.team ? { - name: bookingToReschedule.eventType.team.name, - id: bookingToReschedule.eventType.team.id, - members: [], - } + name: bookingToReschedule.eventType.team.name, + id: bookingToReschedule.eventType.team.id, + members: [], + } : undefined, }); @@ -309,16 +332,4 @@ export const requestRescheduleHandler = async ({ ctx, input, source }: RequestRe }) ); await Promise.all(promises); - - const bookingEventHandlerService = getBookingEventHandlerService(); - await bookingEventHandlerService.onRescheduleRequested({ - bookingUid: bookingToReschedule.uid, - actor: makeUserActor(user.uuid), - organizationId: orgId ?? null, - source, - auditData: { - rescheduleReason: cancellationReason ?? null, - rescheduledRequestedBy: user.email, - }, - }); }; diff --git a/packages/trpc/server/routers/viewer/teams/roundRobin/roundRobinReassign.handler.ts b/packages/trpc/server/routers/viewer/teams/roundRobin/roundRobinReassign.handler.ts index e160ae37242bfd..b52ad36fc2b26d 100644 --- a/packages/trpc/server/routers/viewer/teams/roundRobin/roundRobinReassign.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/roundRobin/roundRobinReassign.handler.ts @@ -31,6 +31,7 @@ export const roundRobinReassignHandler = async ({ ctx, input }: RoundRobinReassi bookingId, orgId: ctx.user.organizationId, reassignedById: ctx.user.id, + reassignedByUuid: ctx.user.uuid, }); };