From 75d611c2e897f9969cb3f2bcc3b81e9a2e452ff7 Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Tue, 13 Jan 2026 18:46:16 +0530 Subject: [PATCH] chore: Integrate creation/rescheduling booking audit for Recurring/regular booking/seated bookings (#26046) * Integrate creation/rescheduling booking audit * fix: add missing hostUserUuid to booking audit test data Co-Authored-By: hariom@cal.com * fix-ci * feat: enhance booking audit with seat reference - Added support for seat reference in booking audit actions. - Updated localization for booking creation to include seat information. - Modified relevant services to pass attendee seat ID during booking creation. * fix: update test data to match schema requirements - Add seatReferenceUid: null to default mock audit log data - Add seatReferenceUid: null to multiple audit logs test case - Convert SEAT_RESCHEDULED test data to use numeric timestamps instead of ISO strings Co-Authored-By: hariom@cal.com * Allow nullish seatReferenceUid * feat: enhance booking audit to support rescheduledBy information - Updated booking audit actions to include rescheduledBy details, allowing tracking of who rescheduled a booking. - Refactored related services to accommodate the new rescheduledBy parameter in booking events. - Adjusted type definitions and function signatures to reflect the changes in the booking audit context. * Avoid possible run time issue * Fix imoport path * fix failing test due to merge from main\ * Pass useruuid --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../controllers/bookings.controller.ts | 1 + .../2024-08-13/services/bookings.service.ts | 17 +- .../lib/modules/recurring-booking.module.ts | 26 +- .../lib/services/recurring-booking.service.ts | 7 +- apps/web/pages/api/book/event.ts | 19 +- apps/web/pages/api/book/recurring-event.ts | 4 +- apps/web/public/static/locales/en/common.json | 19 +- .../lib/actions/CreatedAuditActionService.ts | 21 +- .../actions/ReassignmentAuditActionService.ts | 10 +- .../actions/RescheduledAuditActionService.ts | 6 +- .../SeatRescheduledAuditActionService.ts | 6 +- .../BookingAuditActionServiceRegistry.ts | 3 +- .../BookingAuditProducerService.interface.ts | 22 + .../lib/service/BookingAuditTaskConsumer.ts | 4 +- .../BookingAuditTaskerProducerService.ts | 32 + .../lib/service/BookingAuditViewerService.ts | 4 +- .../BookingAuditViewerService.test.ts | 571 +++++++++--------- .../created-action.integration-test.ts | 7 + .../di/RecurringBookingService.module.ts | 2 + packages/features/bookings/lib/dto/types.d.ts | 1 + .../buildBookingEventAuditData.ts | 52 ++ .../lib/handleNewBooking/createBooking.ts | 8 +- .../lib/handleNewBooking/findBookingQuery.ts | 1 + .../handleNewBooking/getAuditActionSource.ts | 15 + .../getBookingAuditActorForNewBooking.ts | 67 ++ .../bookings/lib/handleSeats/handleSeats.ts | 132 +++- .../owner/moveSeatedBookingToNewTimeSlot.ts | 39 +- .../bookings/lib/handleSeats/types.d.ts | 20 +- ...QualifiedHostsWithDelegationCredentials.ts | 9 +- .../BookingEventHandlerService.ts | 46 ++ .../service/RecurringBookingService.test.ts | 1 + .../lib/service/RecurringBookingService.ts | 173 +++++- .../lib/service/RegularBookingService.ts | 303 +++++++--- .../features/eventtypes/lib/defaultEvents.ts | 1 + packages/features/users/lib/getRoutedUsers.ts | 1 + packages/prisma/selects/user.ts | 1 + 36 files changed, 1198 insertions(+), 453 deletions(-) create mode 100644 packages/features/bookings/lib/handleNewBooking/buildBookingEventAuditData.ts create mode 100644 packages/features/bookings/lib/handleNewBooking/getAuditActionSource.ts create mode 100644 packages/features/bookings/lib/handleNewBooking/getBookingAuditActorForNewBooking.ts 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 8ee9cff134..55ffd326a7 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 @@ -349,6 +349,7 @@ export class BookingsController_2024_04_15 { platformBookingLocation: bookingRequest.platformBookingLocation, noEmail: bookingRequest.body.noEmail, }, + creationSource: "API_V2", }); createdBookings.forEach(async (booking) => { 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 3f6fc7a99f..6b9011e2a2 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 @@ -37,7 +37,6 @@ import { BadRequestException } from "@nestjs/common"; import { Request } from "express"; import { DateTime } from "luxon"; import { z } from "zod"; - export const BOOKING_REASSIGN_PERMISSION_ERROR = "You do not have permission to reassign this booking"; import { @@ -116,7 +115,7 @@ export class BookingsService_2024_08_13 { private readonly recurringBookingService: RecurringBookingService, private readonly instantBookingCreateService: InstantBookingCreateService, private readonly eventTypeAccessService: EventTypeAccessService - ) {} + ) { } async createBooking(request: Request, body: CreateBookingInput, authUser: AuthOptionalUser) { let bookingTeamEventType = false; @@ -322,8 +321,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(", ")}.` ); } @@ -337,8 +335,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(", ")}.` ); } @@ -352,8 +349,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(", ")}.` ); } @@ -368,8 +364,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(", ")}.` ); } @@ -465,6 +460,7 @@ export class BookingsService_2024_08_13 { noEmail: bookingRequest.noEmail, areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled, }, + creationSource: "API_V2", }); const ids = bookings.map((booking) => booking.id || 0); return this.outputService.getOutputRecurringBookings(ids); @@ -489,6 +485,7 @@ export class BookingsService_2024_08_13 { platformBookingLocation: bookingRequest.platformBookingLocation, areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled, }, + creationSource: "API_V2", }); return this.outputService.getOutputCreateRecurringSeatedBookings( bookings.map((booking) => ({ uid: booking.uid || "", seatUid: booking.seatReferenceUid || "" })), diff --git a/apps/api/v2/src/lib/modules/recurring-booking.module.ts b/apps/api/v2/src/lib/modules/recurring-booking.module.ts index bcb5d43f82..b3849bdd30 100644 --- a/apps/api/v2/src/lib/modules/recurring-booking.module.ts +++ b/apps/api/v2/src/lib/modules/recurring-booking.module.ts @@ -1,10 +1,30 @@ import { RegularBookingModule } from "@/lib/modules/regular-booking.module"; import { RecurringBookingService } from "@/lib/services/recurring-booking.service"; import { Module } from "@nestjs/common"; - +import { BookingEventHandlerService } from "@/lib/services/booking-event-handler.service"; +import { Logger } from "@/lib/logger.bridge"; +import { Scope } from "@nestjs/common"; +import { BookingAuditProducerService } from "@/lib/services/booking-audit-producer.service"; +import { HashedLinkService } from "@/lib/services/hashed-link.service"; +import { TaskerService } from "@/lib/services/tasker.service"; @Module({ imports: [RegularBookingModule], - providers: [RecurringBookingService], + providers: [ + RecurringBookingService, + BookingEventHandlerService, + /** Required by BookingEventHandlerService - Starts **/ + HashedLinkService, + { + provide: Logger, + useFactory: () => { + return new Logger(); + }, + scope: Scope.TRANSIENT, + }, + BookingAuditProducerService, + TaskerService, + /** Required by BookingEventHandlerService - Ends **/ + ], exports: [RecurringBookingService], }) -export class RecurringBookingModule {} +export class RecurringBookingModule { } 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 d40b53db45..79888bdf6e 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 894fc8fb92..1d15a45aba 100644 --- a/apps/web/pages/api/book/event.ts +++ b/apps/web/pages/api/book/event.ts @@ -58,24 +58,7 @@ async function handler(req: NextApiRequest & { userId?: number; traceContext: Tr }, }); - // 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"); +export default defaultResponder(handler, "/api/book/event"); \ No newline at end of file diff --git a/apps/web/pages/api/book/recurring-event.ts b/apps/web/pages/api/book/recurring-event.ts index 0fdd9d3cae..ae63038720 100644 --- a/apps/web/pages/api/book/recurring-event.ts +++ b/apps/web/pages/api/book/recurring-event.ts @@ -1,5 +1,4 @@ import type { NextApiRequest } from "next"; - 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"; @@ -55,6 +54,7 @@ async function handler(req: NextApiRequest & RequestMeta) { platformBookingLocation: req.platformBookingLocation, noEmail: req.noEmail, }, + creationSource: "WEBAPP", }); return createdBookings; @@ -62,4 +62,4 @@ async function handler(req: NextApiRequest & RequestMeta) { export const handleRecurringEventBooking = handler; -export default defaultResponder(handler); +export default defaultResponder(handler); \ No newline at end of file diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 378f98fc4d..5974b13983 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -3321,7 +3321,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", @@ -3718,13 +3718,15 @@ "pbac_action_delete": "Delete", "pbac_action_manage": "Manage", "pbac_action_invite": "Invite", - "pbac_action_remove": "Remove", + "pbac_action_remove": "Remove", "pbac_action_change_member_role": "Change member role", "pbac_action_list_members": "List members", "pbac_action_manage_billing": "Manage billing", "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_booking_audit_logs": "View team booking audit logs", + "pbac_action_read_org_booking_audit_logs": "View organization booking audit logs", "pbac_action_impersonate": "Impersonate", "pbac_action_edit_users": "Edit users", "role_created_successfully": "Role created successfully", @@ -3795,6 +3797,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_read_team_booking_audit_logs": "View audit logs for team bookings", + "pbac_desc_read_org_booking_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", @@ -4263,7 +4267,8 @@ "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}}", + "created_with_seat": "Seat Booked with {{host}}", "cancelled": "Cancelled", "rescheduled": "Rescheduled {{oldDate}} -> <0>{{newDate}}", "rescheduled_from": "Rescheduled <0>{{oldDate}} -> {{newDate}}", @@ -4278,9 +4283,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", + "assignment_type_manual": "Manual", + "assignment_type_round_robin": "Round robin", "actor_impersonated_by": "Impersonator", "source": "Source" }, diff --git a/packages/features/booking-audit/lib/actions/CreatedAuditActionService.ts b/packages/features/booking-audit/lib/actions/CreatedAuditActionService.ts index ad616eba04..a2109ddd4c 100644 --- a/packages/features/booking-audit/lib/actions/CreatedAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/CreatedAuditActionService.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { BookingStatus } from "@calcom/prisma/enums"; +import type { UserRepository } from "@calcom/features/users/repositories/UserRepository"; import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, GetDisplayJsonParams } from "./IAuditActionService"; @@ -15,8 +16,14 @@ const fieldsSchemaV1 = z.object({ startTime: z.number(), endTime: z.number(), status: z.nativeEnum(BookingStatus), + hostUserUuid: z.string().nullable(), + // Allowing it to be optional because most of the time(non-seated booking) it won't be there + seatReferenceUid: z.string().nullish(), }); +type Deps = { + userRepository: UserRepository; +}; export class CreatedAuditActionService implements IAuditActionService { readonly VERSION = 1; public static readonly TYPE = "CREATED" as const; @@ -32,7 +39,7 @@ export class CreatedAuditActionService implements IAuditActionService { public static readonly storedFieldsSchema = CreatedAuditActionService.fieldsSchemaV1; private helper: AuditActionServiceHelper; - constructor() { + constructor(private readonly deps: Deps) { this.helper = new AuditActionServiceHelper({ latestVersion: this.VERSION, latestFieldsSchema: CreatedAuditActionService.latestFieldsSchema, @@ -58,8 +65,14 @@ export class CreatedAuditActionService implements IAuditActionService { return { isMigrated: false, latestData: validated }; } - async getDisplayTitle(_: GetDisplayTitleParams): Promise { - return { key: "booking_audit_action.created" }; + async getDisplayTitle({ storedData }: GetDisplayTitleParams): Promise { + const { fields } = this.parseStored(storedData); + const hostUser = fields.hostUserUuid ? await this.deps.userRepository.findByUuid({ uuid: fields.hostUserUuid }) : null; + const hostName = hostUser?.name || "Unknown"; + if (fields.seatReferenceUid) { + return { key: "booking_audit_action.created_with_seat", params: { host: hostName } }; + } + return { key: "booking_audit_action.created", params: { host: hostName } }; } getDisplayJson({ @@ -73,6 +86,7 @@ export class CreatedAuditActionService implements IAuditActionService { startTime: AuditActionServiceHelper.formatDateTimeInTimeZone(fields.startTime, timeZone), endTime: AuditActionServiceHelper.formatDateTimeInTimeZone(fields.endTime, timeZone), status: fields.status, + ...(fields.seatReferenceUid ? { seatReferenceUid: fields.seatReferenceUid } : {}), }; } } @@ -83,5 +97,6 @@ export type CreatedAuditDisplayData = { startTime: string; endTime: string; status: BookingStatus; + seatReferenceUid?: string; }; diff --git a/packages/features/booking-audit/lib/actions/ReassignmentAuditActionService.ts b/packages/features/booking-audit/lib/actions/ReassignmentAuditActionService.ts index c1027655bf..42cbfc80dc 100644 --- a/packages/features/booking-audit/lib/actions/ReassignmentAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/ReassignmentAuditActionService.ts @@ -88,12 +88,16 @@ export class ReassignmentAuditActionService implements IAuditActionService { labelKey: string; valueKey: string; }> { - const { fields } = storedData; - const typeTranslationKey = `booking_audit_action.assignmentType_${fields.reassignmentType}`; + const { fields } = this.parseStored(storedData); + const map = { + manual: "manual", + roundRobin: "round_robin", + } + const typeTranslationKey = `booking_audit_action.assignment_type_${map[fields.reassignmentType]}`; return [ { - labelKey: "booking_audit_action.type", + labelKey: "booking_audit_action.assignment_type", valueKey: typeTranslationKey, } ]; diff --git a/packages/features/booking-audit/lib/actions/RescheduledAuditActionService.ts b/packages/features/booking-audit/lib/actions/RescheduledAuditActionService.ts index fb28fc86be..29997ee14b 100644 --- a/packages/features/booking-audit/lib/actions/RescheduledAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/RescheduledAuditActionService.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { StringChangeSchema } from "../common/changeSchemas"; +import { NumberChangeSchema, StringChangeSchema } from "../common/changeSchemas"; import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, GetDisplayJsonParams, BaseStoredAuditData } from "./IAuditActionService"; @@ -11,8 +11,8 @@ import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, // Module-level because it is passed to IAuditActionService type outside the class scope const fieldsSchemaV1 = z.object({ - startTime: StringChangeSchema, - endTime: StringChangeSchema, + startTime: NumberChangeSchema, + endTime: NumberChangeSchema, rescheduledToUid: StringChangeSchema, }); diff --git a/packages/features/booking-audit/lib/actions/SeatRescheduledAuditActionService.ts b/packages/features/booking-audit/lib/actions/SeatRescheduledAuditActionService.ts index 553d752536..ea64f36162 100644 --- a/packages/features/booking-audit/lib/actions/SeatRescheduledAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/SeatRescheduledAuditActionService.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { StringChangeSchema } from "../common/changeSchemas"; +import { StringChangeSchema, NumberChangeSchema } from "../common/changeSchemas"; import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; import type { IAuditActionService, GetDisplayTitleParams, GetDisplayJsonParams, TranslationWithParams } from "./IAuditActionService"; /** @@ -12,8 +12,8 @@ import type { IAuditActionService, GetDisplayTitleParams, GetDisplayJsonParams, const fieldsSchemaV1 = z.object({ seatReferenceUid: z.string(), attendeeEmail: z.string(), - startTime: StringChangeSchema, - endTime: StringChangeSchema, + startTime: NumberChangeSchema, + endTime: NumberChangeSchema, rescheduledToBookingUid: StringChangeSchema, }); diff --git a/packages/features/booking-audit/lib/service/BookingAuditActionServiceRegistry.ts b/packages/features/booking-audit/lib/service/BookingAuditActionServiceRegistry.ts index d5c311f21e..5ace2104dd 100644 --- a/packages/features/booking-audit/lib/service/BookingAuditActionServiceRegistry.ts +++ b/packages/features/booking-audit/lib/service/BookingAuditActionServiceRegistry.ts @@ -2,7 +2,6 @@ import type { UserRepository } from "@calcom/features/users/repositories/UserRep import type { IAuditActionService } from "../actions/IAuditActionService"; import type { BookingAuditAction } from "../repository/IBookingAuditRepository"; -// Import all action services import { CreatedAuditActionService, type CreatedAuditData } from "../actions/CreatedAuditActionService"; import { CancelledAuditActionService, type CancelledAuditData } from "../actions/CancelledAuditActionService"; import { RescheduledAuditActionService, type RescheduledAuditData } from "../actions/RescheduledAuditActionService"; @@ -54,7 +53,7 @@ export class BookingAuditActionServiceRegistry { constructor(private deps: BookingAuditActionServiceRegistryDeps) { const services: Array<[BookingAuditAction, IAuditActionService]> = [ - ["CREATED", new CreatedAuditActionService()], + ["CREATED", new CreatedAuditActionService(deps)], ["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 54a0ebe7aa..1f4ff249f8 100644 --- a/packages/features/booking-audit/lib/service/BookingAuditProducerService.interface.ts +++ b/packages/features/booking-audit/lib/service/BookingAuditProducerService.interface.ts @@ -201,6 +201,28 @@ export interface BookingAuditProducerService { 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; + queueBulkRejectedAudit(params: { bookings: Array<{ bookingUid: string; diff --git a/packages/features/booking-audit/lib/service/BookingAuditTaskConsumer.ts b/packages/features/booking-audit/lib/service/BookingAuditTaskConsumer.ts index 66440d3ade..c04b669c45 100644 --- a/packages/features/booking-audit/lib/service/BookingAuditTaskConsumer.ts +++ b/packages/features/booking-audit/lib/service/BookingAuditTaskConsumer.ts @@ -271,7 +271,7 @@ export class BookingAuditTaskConsumer { const { organizationId, action, bookingUids } = params; if (organizationId === null) { - logger.info(`Skipping audit for non-organization booking: action=${action}, bookingUids=${bookingUids.join(",")}`); + logger.debug(`Skipping audit for non-organization booking: action=${action}, bookingUids=${bookingUids.join(",")}`); return false; } @@ -281,7 +281,7 @@ export class BookingAuditTaskConsumer { ); if (!isFeatureEnabled) { - logger.info( + logger.debug( `booking-audit feature is disabled for organization: action=${action}, bookingUids=${bookingUids.join(",")}, organizationId=${organizationId}` ); return false; diff --git a/packages/features/booking-audit/lib/service/BookingAuditTaskerProducerService.ts b/packages/features/booking-audit/lib/service/BookingAuditTaskerProducerService.ts index 2df32f9adf..c1e00ffcd9 100644 --- a/packages/features/booking-audit/lib/service/BookingAuditTaskerProducerService.ts +++ b/packages/features/booking-audit/lib/service/BookingAuditTaskerProducerService.ts @@ -402,6 +402,38 @@ export class BookingAuditTaskerProducerService implements BookingAuditProducerSe }); } + 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, + }); + } + async queueBulkRejectedAudit(params: { bookings: Array<{ bookingUid: string; diff --git a/packages/features/booking-audit/lib/service/BookingAuditViewerService.ts b/packages/features/booking-audit/lib/service/BookingAuditViewerService.ts index cb7f3dfafd..5224f04a65 100644 --- a/packages/features/booking-audit/lib/service/BookingAuditViewerService.ts +++ b/packages/features/booking-audit/lib/service/BookingAuditViewerService.ts @@ -125,9 +125,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/lib/service/__tests__/BookingAuditViewerService.test.ts b/packages/features/booking-audit/lib/service/__tests__/BookingAuditViewerService.test.ts index c26feae390..08661076ea 100644 --- a/packages/features/booking-audit/lib/service/__tests__/BookingAuditViewerService.test.ts +++ b/packages/features/booking-audit/lib/service/__tests__/BookingAuditViewerService.test.ts @@ -25,29 +25,74 @@ vi.mock("@calcom/features/bookings/repositories/BookingRepository"); vi.mock("@calcom/features/membership/repositories/MembershipRepository"); vi.mock("@calcom/features/credentials/repositories/CredentialRepository"); -const createMockTeamBooking = (overrides?: { - userId?: number; - teamId?: number | null; - parentTeamId?: number; -}) => ({ - userId: overrides?.userId ?? 456, +type MockBooking = { + userId: number; user: { - id: overrides?.userId ?? 456, - email: "test@example.com", - }, + id: number; + email: string; + }; eventType: { - teamId: (overrides && "teamId" in overrides ? overrides.teamId : (overrides?.teamId ?? 100)) ?? null, - parent: (overrides?.parentTeamId ? { teamId: overrides.parentTeamId } : undefined) ?? null, - hosts: [], - users: [], - }, - attendees: [], -}); + teamId: number | null; + parent: { teamId: number } | null; + hosts: unknown[]; + users: unknown[]; + } | null; + attendees: unknown[]; + fromReschedule?: string | null; +}; + +type MockAttendee = { + id: number; + name: string | null; + email: string; +}; + +type MockMembership = { + userId: number; + teamId: number; +}; + +const DB = { + users: {} as Record, + bookings: {} as Record, + auditLogs: {} as Record, + attendees: {} as Record, + memberships: {} as Record, // key: "userId-teamId" +}; + +const createMockTeamBooking = ( + bookingUid: string, + overrides?: { + userId?: number; + teamId?: number | null; + parentTeamId?: number; + fromReschedule?: string | null; + } +) => { + const booking: MockBooking = { + userId: overrides?.userId ?? 456, + user: { + id: overrides?.userId ?? 456, + email: "test@example.com", + }, + eventType: { + teamId: (overrides && "teamId" in overrides ? overrides.teamId : (overrides?.teamId ?? 100)) ?? null, + parent: (overrides?.parentTeamId ? { teamId: overrides.parentTeamId } : undefined) ?? null, + hosts: [], + users: [], + }, + attendees: [], + fromReschedule: overrides?.fromReschedule, + }; + + DB.bookings[bookingUid] = booking; + return booking; +}; const createMockAuditLog = ( + bookingUid: string, overrides?: Partial<{ id: string; - bookingUid: string; action: BookingAuditAction; type: BookingAuditType; timestamp: Date; @@ -58,45 +103,92 @@ const createMockAuditLog = ( actorType: AuditActorType; actorUserUuid: string | null; actorName: string | null; + actorAttendeeId: number | null; context: BookingAuditContext | null; }> -): BookingAuditWithActor => ({ - id: overrides?.id ?? "audit-log-1", - bookingUid: overrides?.bookingUid ?? "booking-uid-123", - action: overrides?.action ?? "CREATED", - type: overrides?.type ?? "RECORD_CREATED", - timestamp: overrides?.timestamp ?? new Date("2024-01-15T10:00:00Z"), - createdAt: overrides?.createdAt ?? new Date("2024-01-15T10:00:00Z"), - updatedAt: overrides?.updatedAt ?? new Date("2024-01-15T10:00:00Z"), - actorId: overrides?.actorId ?? "actor-1", - data: overrides?.data ?? { - version: 1, - fields: { startTime: 1705315200000, endTime: 1705318800000, status: "ACCEPTED" }, - }, - source: "WEBAPP" as const, - operationId: "operation-id-123", - context: (overrides && "context" in overrides ? overrides.context : null) as BookingAuditContext | null, - actor: { - id: overrides?.actorId ?? "actor-1", - type: overrides?.actorType ?? ("USER" as const), - userUuid: (overrides && "actorUserUuid" in overrides ? overrides.actorUserUuid : "user-uuid-123") as - | string - | null, - attendeeId: null, - credentialId: null, - name: (overrides && "actorName" in overrides ? overrides.actorName : "John Doe") as string | null, - createdAt: new Date("2024-01-01T00:00:00Z"), - }, -}); +): BookingAuditWithActor => { + const log: BookingAuditWithActor = { + id: overrides?.id ?? "audit-log-1", + bookingUid, + action: overrides?.action ?? "CREATED", + type: overrides?.type ?? "RECORD_CREATED", + timestamp: overrides?.timestamp ?? new Date("2024-01-15T10:00:00Z"), + createdAt: overrides?.createdAt ?? new Date("2024-01-15T10:00:00Z"), + updatedAt: overrides?.updatedAt ?? new Date("2024-01-15T10:00:00Z"), + actorId: overrides?.actorId ?? "actor-1", + data: overrides?.data ?? { + version: 1, + fields: { + startTime: 1705315200000, + endTime: 1705318800000, + status: "ACCEPTED", + hostUserUuid: "host-uuid", + }, + }, + source: "WEBAPP" as const, + operationId: "operation-id-123", + context: (overrides && "context" in overrides ? overrides.context : null) as BookingAuditContext | null, + actor: { + id: overrides?.actorId ?? "actor-1", + type: overrides?.actorType ?? ("USER" as const), + userUuid: (overrides && "actorUserUuid" in overrides ? overrides.actorUserUuid : "user-uuid-123") as + | string + | null, + attendeeId: (overrides && "actorAttendeeId" in overrides ? overrides.actorAttendeeId : null) as + | number + | null, + credentialId: null, + name: (overrides && "actorName" in overrides ? overrides.actorName : "John Doe") as string | null, + createdAt: new Date("2024-01-01T00:00:00Z"), + }, + }; + + if (!DB.auditLogs[bookingUid]) { + DB.auditLogs[bookingUid] = []; + } + DB.auditLogs[bookingUid].push(log); + return log; +}; + +type MockUser = { + id: number; + name: string | null; + email: string; + avatarUrl: string | null; +}; const createMockUser = ( + uuid?: string, overrides?: Partial<{ id: number; name: string | null; email: string; avatarUrl: string | null }> -) => ({ - id: overrides?.id ?? 123, - name: (overrides && "name" in overrides ? overrides.name : "John Doe") as string | null, - email: overrides?.email ?? "john@example.com", - avatarUrl: (overrides && "avatarUrl" in overrides ? overrides.avatarUrl : null) as string | null, -}); +) => { + const user: MockUser = { + id: overrides?.id ?? 123, + name: (overrides && "name" in overrides ? overrides.name : "John Doe") as string | null, + email: overrides?.email ?? "john@example.com", + avatarUrl: (overrides && "avatarUrl" in overrides ? overrides.avatarUrl : null) as string | null, + }; + + if (uuid) { + DB.users[uuid] = user; + } + + return user; +}; + +const createMockAttendee = (id: number, overrides?: Partial<{ name: string | null; email: string }>) => { + const attendee: MockAttendee = { + id, + name: overrides?.name ?? null, + email: overrides?.email ?? `attendee-${id}@example.com`, + }; + DB.attendees[id] = attendee; + return attendee; +}; + +const createMockMembership = ({ userId, teamId }: { userId: number; teamId: number }) => { + const key = `${userId}-${teamId}`; + DB.memberships[key] = true; +}; describe("BookingAuditViewerService - Integration Tests", () => { let service: BookingAuditViewerService; @@ -131,25 +223,45 @@ describe("BookingAuditViewerService - Integration Tests", () => { beforeEach(() => { vi.clearAllMocks(); + DB.users = {}; + DB.bookings = {}; + DB.auditLogs = {}; + DB.attendees = {}; + DB.memberships = {}; mockBookingRepository = { - findByUidIncludeEventType: vi.fn(), - getFromRescheduleUid: vi.fn(), + findByUidIncludeEventType: vi.fn().mockImplementation(({ bookingUid }: { bookingUid: string }) => { + return Promise.resolve(DB.bookings[bookingUid] ?? null); + }), + getFromRescheduleUid: vi.fn().mockImplementation((bookingUid: string) => { + const booking = DB.bookings[bookingUid]; + return Promise.resolve(booking?.fromReschedule ?? null); + }), }; mockUserRepository = { getUserOrganizationAndTeams: vi.fn(), - findByUuid: vi.fn(), + findByUuid: vi.fn().mockImplementation(({ uuid }: { uuid: string }) => { + return Promise.resolve(DB.users[uuid] ?? null); + }), }; mockBookingAuditRepository = { create: vi.fn(), - findAllForBooking: vi.fn().mockResolvedValue([]), - findRescheduledLogsOfBooking: vi.fn(), + findAllForBooking: vi.fn().mockImplementation((bookingUid: string) => { + return Promise.resolve(DB.auditLogs[bookingUid] ?? []); + }), + findRescheduledLogsOfBooking: vi.fn().mockImplementation((bookingUid: string) => { + const logs = DB.auditLogs[bookingUid] ?? []; + return Promise.resolve(logs.filter((log) => log.action === "RESCHEDULED")); + }), }; mockMembershipRepository = { - hasMembership: vi.fn(), + hasMembership: vi.fn().mockImplementation(({ userId, teamId }: { userId: number; teamId: number }) => { + const key = `${userId}-${teamId}`; + return Promise.resolve(DB.memberships[key] ?? false); + }), }; mockPermissionCheckService = { @@ -157,7 +269,9 @@ describe("BookingAuditViewerService - Integration Tests", () => { }; mockAttendeeRepository = { - findById: vi.fn(), + findById: vi.fn().mockImplementation((id: number) => { + return Promise.resolve(DB.attendees[id] ?? null); + }), }; mockCredentialRepository = { @@ -198,18 +312,13 @@ describe("BookingAuditViewerService - Integration Tests", () => { describe("getAuditLogsForBooking", () => { describe("when user has permission to view audit logs", () => { beforeEach(() => { - mockBookingRepository.findByUidIncludeEventType.mockResolvedValue( - createMockTeamBooking({ teamId: 100 }) - ); + createMockTeamBooking("booking-uid-123", { teamId: 100 }); mockPermissionCheckService.checkPermission.mockResolvedValue(true); }); it("should return enriched audit logs with actor information", async () => { - const mockLog = createMockAuditLog(); - const mockUser = createMockUser(); - - mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]); - mockUserRepository.findByUuid.mockResolvedValue(mockUser); + createMockAuditLog("booking-uid-123"); + createMockUser("user-uuid-123"); const result = await service.getAuditLogsForBooking({ bookingUid: "booking-uid-123", @@ -236,13 +345,12 @@ describe("BookingAuditViewerService - Integration Tests", () => { }); it("should format timestamps as ISO strings", async () => { - const mockLog = createMockAuditLog({ + createMockAuditLog("booking-uid-123", { timestamp: new Date("2024-01-15T10:00:00Z"), createdAt: new Date("2024-01-15T10:00:00Z"), }); - mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]); - mockUserRepository.findByUuid.mockResolvedValue(createMockUser()); + createMockUser("user-uuid-123"); const result = await service.getAuditLogsForBooking({ bookingUid: "booking-uid-123", @@ -257,10 +365,9 @@ describe("BookingAuditViewerService - Integration Tests", () => { }); it("should include action display title with translation key", async () => { - const mockLog = createMockAuditLog({ action: "CREATED" }); + createMockAuditLog("booking-uid-123", { action: "CREATED" }); - mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]); - mockUserRepository.findByUuid.mockResolvedValue(createMockUser()); + createMockUser("user-uuid-123"); const result = await service.getAuditLogsForBooking({ bookingUid: "booking-uid-123", @@ -272,43 +379,48 @@ describe("BookingAuditViewerService - Integration Tests", () => { expect(result.auditLogs[0].actionDisplayTitle).toEqual({ key: "booking_audit_action.created", + params: { + host: "Unknown", + }, }); }); it("should handle multiple audit logs in correct order", async () => { - const logs = [ - createMockAuditLog({ - id: "log-1", - action: "CREATED", - timestamp: new Date("2024-01-15T10:00:00Z"), - data: { - version: 1, - fields: { startTime: 1705315200000, endTime: 1705318800000, status: "ACCEPTED" }, + createMockAuditLog("booking-uid-123", { + id: "log-1", + action: "CREATED", + timestamp: new Date("2024-01-15T10:00:00Z"), + data: { + version: 1, + fields: { + startTime: 1705315200000, + endTime: 1705318800000, + status: "ACCEPTED", + hostUserUuid: null, }, - }), - createMockAuditLog({ - id: "log-2", - action: "ACCEPTED", - timestamp: new Date("2024-01-15T11:00:00Z"), - data: { version: 1, fields: { status: { old: "PENDING", new: "ACCEPTED" } } }, - }), - createMockAuditLog({ - id: "log-3", - action: "CANCELLED", - timestamp: new Date("2024-01-15T12:00:00Z"), - data: { - version: 1, - fields: { - status: { old: "ACCEPTED", new: "CANCELLED" }, - cancellationReason: "User requested", - cancelledBy: "user-123", - }, + }, + }); + createMockAuditLog("booking-uid-123", { + id: "log-2", + action: "ACCEPTED", + timestamp: new Date("2024-01-15T11:00:00Z"), + data: { version: 1, fields: { status: { old: "PENDING", new: "ACCEPTED" } } }, + }); + createMockAuditLog("booking-uid-123", { + id: "log-3", + action: "CANCELLED", + timestamp: new Date("2024-01-15T12:00:00Z"), + data: { + version: 1, + fields: { + status: { old: "ACCEPTED", new: "CANCELLED" }, + cancellationReason: "User requested", + cancelledBy: "user-123", }, - }), - ]; + }, + }); - mockBookingAuditRepository.findAllForBooking.mockResolvedValue(logs); - mockUserRepository.findByUuid.mockResolvedValue(createMockUser()); + createMockUser("user-uuid-123"); const result = await service.getAuditLogsForBooking({ bookingUid: "booking-uid-123", @@ -325,7 +437,7 @@ describe("BookingAuditViewerService - Integration Tests", () => { }); it("should return empty array when no audit logs exist", async () => { - mockBookingAuditRepository.findAllForBooking.mockResolvedValue([]); + // Don't create any audit logs in DB const result = await service.getAuditLogsForBooking({ bookingUid: "booking-uid-123", @@ -342,26 +454,21 @@ describe("BookingAuditViewerService - Integration Tests", () => { describe("when enriching actor information", () => { beforeEach(() => { - mockBookingRepository.findByUidIncludeEventType.mockResolvedValue( - createMockTeamBooking({ teamId: 100 }) - ); + createMockTeamBooking("booking-uid-123", { teamId: 100 }); mockPermissionCheckService.checkPermission.mockResolvedValue(true); }); it("should enrich USER actor with user details from repository", async () => { - const mockLog = createMockAuditLog({ + createMockAuditLog("booking-uid-123", { actorType: "USER", actorUserUuid: "user-uuid-456", }); - const mockUser = createMockUser({ + createMockUser("user-uuid-456", { name: "Jane Smith", email: "jane@example.com", avatarUrl: "https://example.com/avatar.jpg", }); - mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]); - mockUserRepository.findByUuid.mockResolvedValue(mockUser); - const result = await service.getAuditLogsForBooking({ bookingUid: "booking-uid-123", userId: 123, @@ -379,14 +486,11 @@ describe("BookingAuditViewerService - Integration Tests", () => { }); it("should use email as display name when user name is null", async () => { - const mockLog = createMockAuditLog({ + createMockAuditLog("booking-uid-123", { actorType: "USER", actorName: null, }); - const mockUser = createMockUser({ name: null, email: "user@example.com" }); - - mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]); - mockUserRepository.findByUuid.mockResolvedValue(mockUser); + createMockUser("user-uuid-123", { name: null, email: "user@example.com" }); const result = await service.getAuditLogsForBooking({ bookingUid: "booking-uid-123", @@ -400,10 +504,7 @@ describe("BookingAuditViewerService - Integration Tests", () => { }); it("should show 'Deleted User' when user not found in repository", async () => { - const mockLog = createMockAuditLog({ actorType: "USER" }); - - mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]); - mockUserRepository.findByUuid.mockResolvedValue(null); + createMockAuditLog("booking-uid-123", { actorType: "USER" }); const result = await service.getAuditLogsForBooking({ bookingUid: "booking-uid-123", @@ -421,13 +522,20 @@ describe("BookingAuditViewerService - Integration Tests", () => { }); it("should show 'Cal.com' for SYSTEM actor", async () => { - const mockLog = createMockAuditLog({ + createMockAuditLog("booking-uid-123", { actorType: "SYSTEM", actorUserUuid: null, + data: { + version: 1, + fields: { + startTime: 1705315200000, + endTime: 1705318800000, + status: "ACCEPTED", + hostUserUuid: null, + }, + }, }); - mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]); - const result = await service.getAuditLogsForBooking({ bookingUid: "booking-uid-123", userId: 123, @@ -446,14 +554,12 @@ describe("BookingAuditViewerService - Integration Tests", () => { }); it("should show 'Guest' for GUEST actor without name", async () => { - const mockLog = createMockAuditLog({ + createMockAuditLog("booking-uid-123", { actorType: "GUEST", actorUserUuid: null, actorName: null, }); - mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]); - const result = await service.getAuditLogsForBooking({ bookingUid: "booking-uid-123", userId: 123, @@ -471,14 +577,12 @@ describe("BookingAuditViewerService - Integration Tests", () => { }); it("should use provided name for GUEST actor", async () => { - const mockLog = createMockAuditLog({ + createMockAuditLog("booking-uid-123", { actorType: "GUEST", actorUserUuid: null, actorName: "External Guest", }); - mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]); - const result = await service.getAuditLogsForBooking({ bookingUid: "booking-uid-123", userId: 123, @@ -491,27 +595,14 @@ describe("BookingAuditViewerService - Integration Tests", () => { }); it("should show 'Attendee' for ATTENDEE actor without name", async () => { - const mockLog = createMockAuditLog({ + createMockAuditLog("booking-uid-123", { actorType: "ATTENDEE", actorUserUuid: null, actorName: null, + actorAttendeeId: 999, }); - // Update the mock to include attendeeId in the actor - const logWithAttendeeId = { - ...mockLog, - actor: { - ...mockLog.actor, - attendeeId: 999, - }, - }; - - mockBookingAuditRepository.findAllForBooking.mockResolvedValue([logWithAttendeeId]); - mockAttendeeRepository.findById.mockResolvedValue({ - id: 999, - name: null, - email: "attendee@example.com", - }); + createMockAttendee(999, { name: null, email: "attendee@example.com" }); const result = await service.getAuditLogsForBooking({ bookingUid: "booking-uid-123", @@ -531,27 +622,14 @@ describe("BookingAuditViewerService - Integration Tests", () => { }); it("should use provided name for ATTENDEE actor", async () => { - const mockLog = createMockAuditLog({ + createMockAuditLog("booking-uid-123", { actorType: "ATTENDEE", actorUserUuid: null, actorName: "Meeting Participant", + actorAttendeeId: 888, }); - // Update the mock to include attendeeId in the actor - const logWithAttendeeId = { - ...mockLog, - actor: { - ...mockLog.actor, - attendeeId: 888, - }, - }; - - mockBookingAuditRepository.findAllForBooking.mockResolvedValue([logWithAttendeeId]); - mockAttendeeRepository.findById.mockResolvedValue({ - id: 888, - name: "Meeting Participant", - email: "participant@example.com", - }); + createMockAttendee(888, { name: "Meeting Participant", email: "participant@example.com" }); const result = await service.getAuditLogsForBooking({ bookingUid: "booking-uid-123", @@ -567,23 +645,14 @@ describe("BookingAuditViewerService - Integration Tests", () => { }); it("should show 'Deleted Attendee' when attendee not found in repository", async () => { - const mockLog = createMockAuditLog({ + createMockAuditLog("booking-uid-123", { actorType: "ATTENDEE", actorUserUuid: null, actorName: null, + actorAttendeeId: 777, }); - // Update the mock to include attendeeId in the actor - const logWithAttendeeId = { - ...mockLog, - actor: { - ...mockLog.actor, - attendeeId: 777, - }, - }; - - mockBookingAuditRepository.findAllForBooking.mockResolvedValue([logWithAttendeeId]); - mockAttendeeRepository.findById.mockResolvedValue(null); + // Don't create attendee in DB - should return null const result = await service.getAuditLogsForBooking({ bookingUid: "booking-uid-123", @@ -605,37 +674,31 @@ describe("BookingAuditViewerService - Integration Tests", () => { describe("when handling rescheduled bookings", () => { beforeEach(() => { - mockBookingRepository.findByUidIncludeEventType.mockResolvedValue( - createMockTeamBooking({ teamId: 100 }) - ); + createMockTeamBooking("new-booking-uid", { teamId: 100 }); mockPermissionCheckService.checkPermission.mockResolvedValue(true); }); it("should include rescheduled from log when booking was created from reschedule", async () => { - const currentBookingLog = createMockAuditLog({ + createMockAuditLog("new-booking-uid", { id: "current-log", - bookingUid: "new-booking-uid", action: "CREATED", }); - const rescheduledLog = createMockAuditLog({ + createMockAuditLog("old-booking-uid", { id: "rescheduled-log", - bookingUid: "old-booking-uid", action: "RESCHEDULED", data: { version: 1, fields: { - startTime: { old: "2024-01-15T10:00:00Z", new: "2024-01-16T10:00:00Z" }, - endTime: { old: "2024-01-15T11:00:00Z", new: "2024-01-16T11:00:00Z" }, + startTime: { old: 1705315200000, new: 1705401600000 }, + endTime: { old: 1705318800000, new: 1705405200000 }, rescheduledToUid: { old: null, new: "new-booking-uid" }, }, }, }); - mockBookingAuditRepository.findAllForBooking.mockResolvedValue([currentBookingLog]); - mockBookingRepository.getFromRescheduleUid.mockResolvedValue("old-booking-uid"); - mockBookingAuditRepository.findRescheduledLogsOfBooking.mockResolvedValue([rescheduledLog]); - mockUserRepository.findByUuid.mockResolvedValue(createMockUser()); + createMockTeamBooking("new-booking-uid", { teamId: 100, fromReschedule: "old-booking-uid" }); + createMockUser("user-uuid-123"); const result = await service.getAuditLogsForBooking({ bookingUid: "new-booking-uid", @@ -646,19 +709,18 @@ describe("BookingAuditViewerService - Integration Tests", () => { }); expect(result.auditLogs).toHaveLength(2); - expect(result.auditLogs[0].id).toBe("rescheduled-log"); - expect(result.auditLogs[0].bookingUid).toBe("new-booking-uid"); - expect(result.auditLogs[0].actionDisplayTitle.key).toBe("booking_audit_action.rescheduled_from"); - expect(result.auditLogs[0].displayJson).toHaveProperty("rescheduledFromUid", "old-booking-uid"); - expect(result.auditLogs[1].id).toBe("current-log"); + expect(result.auditLogs[1].id).toBe("rescheduled-log"); + expect(result.auditLogs[1].bookingUid).toBe("new-booking-uid"); + expect(result.auditLogs[1].actionDisplayTitle.key).toBe("booking_audit_action.rescheduled_from"); + expect(result.auditLogs[1].displayJson).toHaveProperty("rescheduledFromUid", "old-booking-uid"); + expect(result.auditLogs[0].id).toBe("current-log"); }); it("should not include rescheduled from log when booking was not rescheduled", async () => { - const mockLog = createMockAuditLog(); + createMockAuditLog("booking-uid-123"); - mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]); - mockBookingRepository.getFromRescheduleUid.mockResolvedValue(null); - mockUserRepository.findByUuid.mockResolvedValue(createMockUser()); + createMockTeamBooking("booking-uid-123", { teamId: 100 }); + createMockUser("user-uuid-123"); const result = await service.getAuditLogsForBooking({ bookingUid: "booking-uid-123", @@ -673,28 +735,23 @@ describe("BookingAuditViewerService - Integration Tests", () => { }); it("should continue without rescheduled from log when matching log not found", async () => { - const currentBookingLog = createMockAuditLog({ - bookingUid: "new-booking-uid", - }); + createMockAuditLog("new-booking-uid"); - const rescheduledLog = createMockAuditLog({ + createMockAuditLog("old-booking-uid", { id: "rescheduled-log", - bookingUid: "old-booking-uid", action: "RESCHEDULED", data: { version: 1, fields: { - startTime: { old: "2024-01-15T10:00:00Z", new: "2024-01-16T10:00:00Z" }, - endTime: { old: "2024-01-15T11:00:00Z", new: "2024-01-16T11:00:00Z" }, + startTime: { old: 1705315200000, new: 1705401600000 }, + endTime: { old: 1705318800000, new: 1705405200000 }, rescheduledToUid: { old: null, new: "different-booking-uid" }, }, }, }); - mockBookingAuditRepository.findAllForBooking.mockResolvedValue([currentBookingLog]); - mockBookingRepository.getFromRescheduleUid.mockResolvedValue("old-booking-uid"); - mockBookingAuditRepository.findRescheduledLogsOfBooking.mockResolvedValue([rescheduledLog]); - mockUserRepository.findByUuid.mockResolvedValue(createMockUser()); + createMockTeamBooking("new-booking-uid", { teamId: 100, fromReschedule: "old-booking-uid" }); + createMockUser("user-uuid-123"); const result = await service.getAuditLogsForBooking({ bookingUid: "new-booking-uid", @@ -714,11 +771,9 @@ describe("BookingAuditViewerService - Integration Tests", () => { describe("when permission check fails", () => { it("should throw error when user lacks permission", async () => { - mockBookingRepository.findByUidIncludeEventType.mockResolvedValue( - createMockTeamBooking({ teamId: 100 }) - ); + createMockTeamBooking("booking-uid-123", { teamId: 100 }); mockPermissionCheckService.checkPermission.mockResolvedValue(false); - mockMembershipRepository.hasMembership.mockResolvedValue(true); + createMockMembership({ userId: 123, teamId: 100 }); await expect( service.getAuditLogsForBooking({ @@ -748,7 +803,7 @@ describe("BookingAuditViewerService - Integration Tests", () => { }); it("should throw error when booking not found", async () => { - mockBookingRepository.findByUidIncludeEventType.mockResolvedValue(null); + // Don't create booking in DB - should return null await expect( service.getAuditLogsForBooking({ @@ -766,27 +821,24 @@ describe("BookingAuditViewerService - Integration Tests", () => { describe("when handling different timezones", () => { beforeEach(() => { - mockBookingRepository.findByUidIncludeEventType.mockResolvedValue( - createMockTeamBooking({ teamId: 100 }) - ); + createMockTeamBooking("booking-uid-123", { teamId: 100 }); mockPermissionCheckService.checkPermission.mockResolvedValue(true); }); it("should pass user timezone to action service for display formatting", async () => { - const mockLog = createMockAuditLog({ + createMockAuditLog("booking-uid-123", { action: "RESCHEDULED", data: { version: 1, fields: { - startTime: { old: "2024-01-15T10:00:00Z", new: "2024-01-16T10:00:00Z" }, - endTime: { old: "2024-01-15T11:00:00Z", new: "2024-01-16T11:00:00Z" }, + startTime: { old: 1705315200000, new: 1705401600000 }, + endTime: { old: 1705318800000, new: 1705405200000 }, rescheduledToUid: { old: null, new: "new-booking-uid" }, }, }, }); - mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]); - mockUserRepository.findByUuid.mockResolvedValue(createMockUser()); + createMockUser("user-uuid-123"); const result = await service.getAuditLogsForBooking({ bookingUid: "booking-uid-123", @@ -803,14 +855,12 @@ describe("BookingAuditViewerService - Integration Tests", () => { describe("when handling seat audit actions", () => { beforeEach(() => { - mockBookingRepository.findByUidIncludeEventType.mockResolvedValue( - createMockTeamBooking({ teamId: 100 }) - ); + createMockTeamBooking("booking-uid-123", { teamId: 100 }); mockPermissionCheckService.checkPermission.mockResolvedValue(true); }); it("should handle SEAT_BOOKED action with seat information", async () => { - const mockLog = createMockAuditLog({ + createMockAuditLog("booking-uid-123", { id: "seat-booked-log", action: "SEAT_BOOKED", data: { @@ -825,8 +875,7 @@ describe("BookingAuditViewerService - Integration Tests", () => { }, }); - mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]); - mockUserRepository.findByUuid.mockResolvedValue(createMockUser()); + createMockUser("user-uuid-123"); const result = await service.getAuditLogsForBooking({ bookingUid: "booking-uid-123", @@ -843,7 +892,7 @@ describe("BookingAuditViewerService - Integration Tests", () => { }); it("should handle SEAT_RESCHEDULED action with time changes", async () => { - const mockLog = createMockAuditLog({ + createMockAuditLog("booking-uid-123", { id: "seat-rescheduled-log", action: "SEAT_RESCHEDULED", data: { @@ -852,12 +901,12 @@ describe("BookingAuditViewerService - Integration Tests", () => { seatReferenceUid: "seat-ref-123", attendeeEmail: "attendee@example.com", startTime: { - old: "2024-01-15T10:00:00Z", - new: "2024-01-16T10:00:00Z", + old: new Date("2024-01-15T10:00:00Z").getTime(), + new: new Date("2024-01-16T10:00:00Z").getTime(), }, endTime: { - old: "2024-01-15T11:00:00Z", - new: "2024-01-16T11:00:00Z", + old: new Date("2024-01-15T11:00:00Z").getTime(), + new: new Date("2024-01-16T11:00:00Z").getTime(), }, rescheduledToBookingUid: { old: null, @@ -867,8 +916,7 @@ describe("BookingAuditViewerService - Integration Tests", () => { }, }); - mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]); - mockUserRepository.findByUuid.mockResolvedValue(createMockUser()); + createMockUser("user-uuid-123"); const result = await service.getAuditLogsForBooking({ bookingUid: "booking-uid-123", @@ -887,7 +935,7 @@ describe("BookingAuditViewerService - Integration Tests", () => { }); it("should handle SEAT_RESCHEDULED action without moving to different booking", async () => { - const mockLog = createMockAuditLog({ + createMockAuditLog("booking-uid-123", { id: "seat-rescheduled-same-booking", action: "SEAT_RESCHEDULED", data: { @@ -896,12 +944,12 @@ describe("BookingAuditViewerService - Integration Tests", () => { seatReferenceUid: "seat-ref-123", attendeeEmail: "attendee@example.com", startTime: { - old: "2024-01-15T10:00:00Z", - new: "2024-01-15T11:00:00Z", + old: new Date("2024-01-15T10:00:00Z").getTime(), + new: new Date("2024-01-15T11:00:00Z").getTime(), }, endTime: { - old: "2024-01-15T11:00:00Z", - new: "2024-01-15T12:00:00Z", + old: new Date("2024-01-15T11:00:00Z").getTime(), + new: new Date("2024-01-15T12:00:00Z").getTime(), }, rescheduledToBookingUid: { old: null, @@ -911,8 +959,7 @@ describe("BookingAuditViewerService - Integration Tests", () => { }, }); - mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]); - mockUserRepository.findByUuid.mockResolvedValue(createMockUser()); + createMockUser("user-uuid-123"); const result = await service.getAuditLogsForBooking({ bookingUid: "booking-uid-123", @@ -930,26 +977,22 @@ describe("BookingAuditViewerService - Integration Tests", () => { describe("when handling impersonatedBy context", () => { beforeEach(() => { - mockBookingRepository.findByUidIncludeEventType.mockResolvedValue( - createMockTeamBooking({ teamId: 100 }) - ); + createMockTeamBooking("booking-uid-123", { teamId: 100 }); mockPermissionCheckService.checkPermission.mockResolvedValue(true); }); it("should enrich impersonatedBy when user exists", async () => { - const mockLog = createMockAuditLog({ + createMockAuditLog("booking-uid-123", { context: { impersonatedBy: "impersonator-uuid-456" }, }); - const impersonatorUser = createMockUser({ + createMockUser("user-uuid-123"); + createMockUser("impersonator-uuid-456", { id: 456, name: "Admin User", email: "admin@example.com", avatarUrl: "https://example.com/admin-avatar.jpg", }); - - mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]); - mockUserRepository.findByUuid.mockResolvedValueOnce(createMockUser()); // For actor - mockUserRepository.findByUuid.mockResolvedValueOnce(impersonatorUser); // For impersonator + // Host lookup should return null - don't create host-uuid in DB const result = await service.getAuditLogsForBooking({ bookingUid: "booking-uid-123", @@ -968,19 +1011,17 @@ describe("BookingAuditViewerService - Integration Tests", () => { }); it("should use email as displayName when impersonator name is null", async () => { - const mockLog = createMockAuditLog({ + createMockAuditLog("booking-uid-123", { context: { impersonatedBy: "impersonator-uuid-456" }, }); - const impersonatorUser = createMockUser({ + createMockUser("user-uuid-123"); + createMockUser("impersonator-uuid-456", { id: 456, name: null, email: "admin@example.com", avatarUrl: null, }); - - mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]); - mockUserRepository.findByUuid.mockResolvedValueOnce(createMockUser()); // For actor - mockUserRepository.findByUuid.mockResolvedValueOnce(impersonatorUser); // For impersonator + // Host lookup should return null - don't create host-uuid in DB const result = await service.getAuditLogsForBooking({ bookingUid: "booking-uid-123", @@ -998,13 +1039,11 @@ describe("BookingAuditViewerService - Integration Tests", () => { }); it("should show 'Deleted User' when impersonator user not found", async () => { - const mockLog = createMockAuditLog({ + createMockAuditLog("booking-uid-123", { context: { impersonatedBy: "impersonator-uuid-456" }, }); - - mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]); - mockUserRepository.findByUuid.mockResolvedValueOnce(createMockUser()); // For actor - mockUserRepository.findByUuid.mockResolvedValueOnce(null); // For impersonator (not found) + createMockUser("user-uuid-123"); + // Don't create impersonator-uuid-456 in DB - should return null const result = await service.getAuditLogsForBooking({ bookingUid: "booking-uid-123", @@ -1023,12 +1062,11 @@ describe("BookingAuditViewerService - Integration Tests", () => { }); it("should return null when context is null", async () => { - const mockLog = createMockAuditLog({ + createMockAuditLog("booking-uid-123", { context: null, }); - mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]); - mockUserRepository.findByUuid.mockResolvedValue(createMockUser()); + createMockUser("user-uuid-123"); const result = await service.getAuditLogsForBooking({ bookingUid: "booking-uid-123", @@ -1039,17 +1077,14 @@ describe("BookingAuditViewerService - Integration Tests", () => { }); expect(result.auditLogs[0].impersonatedBy).toBeNull(); - // Should not call findByUuid for impersonator when context is null - expect(mockUserRepository.findByUuid).toHaveBeenCalledTimes(1); // Only for actor }); it("should return null when impersonatedBy is not in context", async () => { - const mockLog = createMockAuditLog({ + createMockAuditLog("booking-uid-123", { context: {}, }); - mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]); - mockUserRepository.findByUuid.mockResolvedValue(createMockUser()); + createMockUser("user-uuid-123"); const result = await service.getAuditLogsForBooking({ bookingUid: "booking-uid-123", @@ -1060,8 +1095,6 @@ describe("BookingAuditViewerService - Integration Tests", () => { }); expect(result.auditLogs[0].impersonatedBy).toBeNull(); - // Should not call findByUuid for impersonator when impersonatedBy is not in context - expect(mockUserRepository.findByUuid).toHaveBeenCalledTimes(1); // Only for actor }); }); }); diff --git a/packages/features/booking-audit/lib/service/__tests__/created-action.integration-test.ts b/packages/features/booking-audit/lib/service/__tests__/created-action.integration-test.ts index dbb922ab83..10a47d53de 100644 --- a/packages/features/booking-audit/lib/service/__tests__/created-action.integration-test.ts +++ b/packages/features/booking-audit/lib/service/__tests__/created-action.integration-test.ts @@ -92,6 +92,7 @@ describe("Created Action Integration", () => { startTime: testData.booking.startTime.getTime(), endTime: testData.booking.endTime.getTime(), status: testData.booking.status, + hostUserUuid: testData.owner.uuid, }, timestamp: Date.now(), }); @@ -138,6 +139,7 @@ describe("Created Action Integration", () => { startTime: testData.booking.startTime.getTime(), endTime: testData.booking.endTime.getTime(), status: testData.booking.status, + hostUserUuid: testData.owner.uuid, }, timestamp: Date.now(), }); @@ -172,6 +174,7 @@ describe("Created Action Integration", () => { startTime: testData.booking.startTime.getTime(), endTime: testData.booking.endTime.getTime(), status: testData.booking.status, + hostUserUuid: testData.owner.uuid, }, timestamp: Date.now(), context: { @@ -211,6 +214,7 @@ describe("Created Action Integration", () => { startTime: testData.booking.startTime.getTime(), endTime: testData.booking.endTime.getTime(), status: testData.booking.status, + hostUserUuid: testData.owner.uuid, }, timestamp: Date.now(), }); @@ -275,6 +279,7 @@ describe("Created Action Integration", () => { startTime: testData.booking.startTime.getTime(), endTime: testData.booking.endTime.getTime(), status: testData.booking.status, + hostUserUuid: testData.owner.uuid, }, }, { @@ -283,6 +288,7 @@ describe("Created Action Integration", () => { startTime: booking2.startTime.getTime(), endTime: booking2.endTime.getTime(), status: booking2.status, + hostUserUuid: testData.owner.uuid, }, }, { @@ -291,6 +297,7 @@ describe("Created Action Integration", () => { startTime: booking3.startTime.getTime(), endTime: booking3.endTime.getTime(), status: booking3.status, + hostUserUuid: testData.owner.uuid, }, }, ], diff --git a/packages/features/bookings/di/RecurringBookingService.module.ts b/packages/features/bookings/di/RecurringBookingService.module.ts index c6846da3f3..06f1f69a8a 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 47161ca3e7..d3ed978b24 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/handleNewBooking/buildBookingEventAuditData.ts b/packages/features/bookings/lib/handleNewBooking/buildBookingEventAuditData.ts new file mode 100644 index 0000000000..597e6576cf --- /dev/null +++ b/packages/features/bookings/lib/handleNewBooking/buildBookingEventAuditData.ts @@ -0,0 +1,52 @@ +import type { BookingStatus } from "@calcom/prisma/enums"; + +export const buildBookingCreatedAuditData = ({ + booking, + attendeeSeatId, +}: { + attendeeSeatId: string | null; + booking: { + startTime: Date; + endTime: Date; + status: BookingStatus; + userUuid: string | null; + }; +}) => { + return { + startTime: booking.startTime.getTime(), + endTime: booking.endTime.getTime(), + status: booking.status, + hostUserUuid: booking.userUuid, + seatReferenceUid: attendeeSeatId, + }; +}; + +export const buildBookingRescheduledAuditData = ({ + oldBooking, + newBooking, +}: { + oldBooking: { + startTime: Date; + endTime: Date; + }; + newBooking: { + startTime: Date; + endTime: Date; + uid: string; + }; +}) => { + return { + startTime: { + old: oldBooking.startTime.getTime(), + new: newBooking.startTime.getTime(), + }, + endTime: { + old: oldBooking.endTime.getTime(), + new: newBooking.endTime.getTime(), + }, + rescheduledToUid: { + old: null, + new: newBooking.uid, + }, + }; +}; diff --git a/packages/features/bookings/lib/handleNewBooking/createBooking.ts b/packages/features/bookings/lib/handleNewBooking/createBooking.ts index fff6b0f342..e5fe34cf54 100644 --- a/packages/features/bookings/lib/handleNewBooking/createBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking/createBooking.ts @@ -143,7 +143,7 @@ async function saveBooking( const createBookingObj = { include: { user: { - select: { email: true, name: true, timeZone: true, username: true }, + select: { uuid: true, email: true, name: true, timeZone: true, username: true }, }, attendees: true, payment: true, @@ -182,7 +182,7 @@ async function saveBooking( await tx.app_RoutingForms_FormResponse.update(reroutingFormResponseUpdateData); } - return booking; + return { ...booking, userUuid: booking.user?.uuid ?? null }; }); } @@ -258,8 +258,8 @@ function buildNewBookingData(params: CreateBookingParams) { destinationCalendar: evt.destinationCalendar && evt.destinationCalendar.length > 0 ? { - connect: { id: evt.destinationCalendar[0].id }, - } + connect: { id: evt.destinationCalendar[0].id }, + } : undefined, routedFromRoutingFormReponse: routingFormResponseId diff --git a/packages/features/bookings/lib/handleNewBooking/findBookingQuery.ts b/packages/features/bookings/lib/handleNewBooking/findBookingQuery.ts index 0cd3312700..6ed36325a0 100644 --- a/packages/features/bookings/lib/handleNewBooking/findBookingQuery.ts +++ b/packages/features/bookings/lib/handleNewBooking/findBookingQuery.ts @@ -19,6 +19,7 @@ const _findBookingQuery = async (bookingId: number) => { metadata: true, user: { select: { + uuid: true, name: true, email: true, timeZone: true, diff --git a/packages/features/bookings/lib/handleNewBooking/getAuditActionSource.ts b/packages/features/bookings/lib/handleNewBooking/getAuditActionSource.ts new file mode 100644 index 0000000000..c61e0d37db --- /dev/null +++ b/packages/features/bookings/lib/handleNewBooking/getAuditActionSource.ts @@ -0,0 +1,15 @@ +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 || creationSource === CreationSource.API_V2 || creationSource === CreationSource.WEBAPP) { + return creationSource; + } + // 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 0000000000..d088e3e6cb --- /dev/null +++ b/packages/features/bookings/lib/handleNewBooking/getBookingAuditActorForNewBooking.ts @@ -0,0 +1,67 @@ +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({ + bookerAttendeeId, + actorUserUuid, + rescheduledBy, + bookerEmail, + logger, + bookerName, +}: { + bookerAttendeeId: number | null; + actorUserUuid: string | null; + bookerEmail: string; + bookerName: string; + logger: ISimpleLogger; + rescheduledBy: + | { + attendeeId: number | null; + email: string; + } + | { + userUuid: string | null; + email: string; + } + | null; +}) { + if (actorUserUuid) { + return makeUserActor(actorUserUuid); + } + + if (rescheduledBy) { + // If rescheduledBy is available we prefer that above considering booker as the actor + if ("attendeeId" in rescheduledBy && rescheduledBy.attendeeId) { + return makeAttendeeActor(rescheduledBy.attendeeId); + } + + if ("userUuid" in rescheduledBy && rescheduledBy.userUuid) { + // We consider that a user actor did it without verifying the authorization, so whn taking the action we could record in the context that this action was taken through rescheduledBy contex, similar to how we would do impersonatedBy in context + // as introduced in PR: https://github.com/calcom/cal.com/pull/26014 + return makeUserActor(rescheduledBy.userUuid); + } + + return makeGuestActor({ email: rescheduledBy.email, name: rescheduledBy.email }); + } + + if (bookerAttendeeId) { + return makeAttendeeActor(bookerAttendeeId); + } + + // 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 ecc37f4815..62b696d63a 100644 --- a/packages/features/bookings/lib/handleSeats/handleSeats.ts +++ b/packages/features/bookings/lib/handleSeats/handleSeats.ts @@ -7,11 +7,123 @@ import { ErrorCode } from "@calcom/lib/errorCodes"; import { HttpError } from "@calcom/lib/http-error"; import prisma from "@calcom/prisma"; import { BookingStatus } from "@calcom/prisma/enums"; - +import { safeStringify } from "@calcom/lib/safeStringify"; 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"; +import type { BookingEventHandlerService } from "../onBookingEvents/BookingEventHandlerService"; +import type { ActionSource } from "@calcom/features/booking-audit/lib/types/actionSource"; +import type { ISimpleLogger } from "@calcom/features/di/shared/services/logger.service"; + +const fireBookingEvents = async ({ + previousSeatedBooking, + newBooking, + originalRescheduledBooking, + rescheduleUid, + organizationId, + bookerEmail, + bookerName, + rescheduledBy, + actionSource, + actorUserUuid, + deps, +}: { + previousSeatedBooking: SeatedBooking; + newBooking: NonNullable; + originalRescheduledBooking: NewSeatedBookingObject["originalRescheduledBooking"]; + rescheduleUid: string | undefined; + organizationId: number | null | undefined; + bookerEmail: string; + bookerName: string; + rescheduledBy: string | undefined; + actionSource: ActionSource; + actorUserUuid: string | null; + deps: { + bookingEventHandler: BookingEventHandlerService; + logger: ISimpleLogger; + }; +}) => { + try { + const bookerAttendeeId = newBooking.attendees?.find((attendee) => attendee.email === bookerEmail)?.id; + const rescheduledByAttendeeId = newBooking.attendees?.find( + (attendee) => attendee.email === rescheduledBy + )?.id; + const rescheduledByUserUuid = newBooking.user?.email === rescheduledBy ? newBooking.user?.uuid : null; + + const auditActor = getBookingAuditActorForNewBooking({ + bookerAttendeeId: bookerAttendeeId ?? null, + actorUserUuid, + bookerEmail, + bookerName, + rescheduledBy: rescheduledBy + ? { + attendeeId: rescheduledByAttendeeId ?? null, + userUuid: rescheduledByUserUuid ?? null, + email: rescheduledBy, + } + : null, + logger: deps.logger, + }); + + const seatReferenceUid = newBooking.seatReferenceUid; + if (!seatReferenceUid) { + return; + } + + if (rescheduleUid && originalRescheduledBooking) { + const movedToDifferentBooking = newBooking.uid && newBooking.uid !== previousSeatedBooking.uid; + const newBookingStartTimeMs = + movedToDifferentBooking && newBooking.startTime + ? newBooking.startTime.getTime() + : previousSeatedBooking.startTime.getTime(); + const newBookingEndTimeMs = + movedToDifferentBooking && newBooking.endTime + ? newBooking.endTime.getTime() + : previousSeatedBooking.endTime.getTime(); + + await deps.bookingEventHandler.onSeatRescheduled({ + bookingUid: previousSeatedBooking.uid, + actor: auditActor, + organizationId: organizationId ?? null, + auditData: { + seatReferenceUid, + attendeeEmail: bookerEmail, + startTime: { + old: originalRescheduledBooking.startTime.getTime(), + new: newBookingStartTimeMs, + }, + endTime: { + old: originalRescheduledBooking.endTime.getTime(), + new: newBookingEndTimeMs, + }, + rescheduledToBookingUid: { + old: null, + new: movedToDifferentBooking ? newBooking.uid || null : null, + }, + }, + source: actionSource, + }); + } else { + await deps.bookingEventHandler.onSeatBooked({ + bookingUid: previousSeatedBooking.uid, + actor: auditActor, + organizationId: organizationId ?? null, + auditData: { + seatReferenceUid, + attendeeEmail: bookerEmail, + attendeeName: bookerName, + startTime: previousSeatedBooking.startTime.getTime(), + endTime: previousSeatedBooking.endTime.getTime(), + }, + source: actionSource, + }); + } + } catch (error) { + deps.logger.error("Error while firing booking events", safeStringify(error)); + } +}; const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => { const { @@ -23,6 +135,7 @@ const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => { bookerEmail, smsReminderNumber, eventTypeInfo, + reqUserUuid, uid, originalRescheduledBooking, reqBodyMetadata, @@ -34,7 +147,11 @@ const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => { rescheduledBy, rescheduleReason, isDryRun = false, + organizationId, + fullName, traceContext, + actionSource, + deps: { bookingEventHandler }, } = newSeatedBookingObject; // TODO: We could allow doing more things to support good dry run for seats if (isDryRun) return; @@ -104,6 +221,19 @@ const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => { // If the resultBooking is defined we should trigger workflows else, trigger in handleNewBooking if (resultBooking) { + await fireBookingEvents({ + previousSeatedBooking: seatedBooking, + newBooking: resultBooking, + originalRescheduledBooking, + rescheduleUid, + organizationId, + bookerEmail, + bookerName: fullName, + rescheduledBy, + actionSource, + actorUserUuid: reqUserUuid ?? null, + deps: { bookingEventHandler, logger: loggerWithEventDetails }, + }); const metadata = { ...(typeof resultBooking.metadata === "object" && resultBooking.metadata), ...reqBodyMetadata, diff --git a/packages/features/bookings/lib/handleSeats/reschedule/owner/moveSeatedBookingToNewTimeSlot.ts b/packages/features/bookings/lib/handleSeats/reschedule/owner/moveSeatedBookingToNewTimeSlot.ts index 7b8744d67f..d0994d60a5 100644 --- a/packages/features/bookings/lib/handleSeats/reschedule/owner/moveSeatedBookingToNewTimeSlot.ts +++ b/packages/features/bookings/lib/handleSeats/reschedule/owner/moveSeatedBookingToNewTimeSlot.ts @@ -1,4 +1,4 @@ - + import { cloneDeep } from "lodash"; import { sendRescheduledEmailsAndSMS } from "@calcom/emails/email-manager"; @@ -13,6 +13,26 @@ import { handleAppsStatus } from "../../../handleNewBooking/handleAppsStatus"; import type { createLoggerWithEventDetails } from "../../../handleNewBooking/logger"; import type { SeatedBooking, RescheduleSeatedBookingObject } from "../../types"; +async function updateBooking({ bookingId, startTime, endTime, cancellationReason }: { bookingId: number, startTime: string, endTime: string, cancellationReason: string }): Promise<(Booking & { appsStatus?: AppsStatus[] })> { + const booking = await prisma.booking.update({ + where: { + id: bookingId, + }, + data: { + startTime, + endTime, + cancellationReason, + }, + include: { + user: true, + references: true, + payment: true, + attendees: true, + }, + }); + return { ...booking, userUuid: booking.user?.uuid ?? null }; +} + const moveSeatedBookingToNewTimeSlot = async ( rescheduleSeatedBookingObject: RescheduleSeatedBookingObject, seatedBooking: SeatedBooking, @@ -31,22 +51,7 @@ const moveSeatedBookingToNewTimeSlot = async ( } = rescheduleSeatedBookingObject; let { evt } = rescheduleSeatedBookingObject; - const newBooking: (Booking & { appsStatus?: AppsStatus[] }) | null = await prisma.booking.update({ - where: { - id: seatedBooking.id, - }, - data: { - startTime: evt.startTime, - endTime: evt.endTime, - cancellationReason: rescheduleReason, - }, - include: { - user: true, - references: true, - payment: true, - attendees: true, - }, - }); + const newBooking = await updateBooking({ bookingId: seatedBooking.id, startTime: evt.startTime, endTime: evt.endTime, cancellationReason: rescheduleReason }); evt = { ...addVideoCallDataToEvent(newBooking.references, evt), bookerUrl: evt.bookerUrl }; diff --git a/packages/features/bookings/lib/handleSeats/types.d.ts b/packages/features/bookings/lib/handleSeats/types.d.ts index 64dca252ad..ecf319660f 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; + reqUserUuid?: string | null; rescheduleReason: RescheduleReason; reqBodyUser: string | string[] | undefined; noEmail: NoEmail; @@ -59,7 +62,12 @@ export type NewSeatedBookingObject = { rescheduledBy?: string; workflows: Workflow[]; isDryRun?: boolean; + organizationId?: number | null; + actionSource: ActionSource; traceContext: TraceContext; + deps: { + bookingEventHandler: BookingEventHandlerService; + } }; export type RescheduleSeatedBookingObject = NewSeatedBookingObject & { rescheduleUid: string }; @@ -81,12 +89,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/host-filtering/findQualifiedHostsWithDelegationCredentials.ts b/packages/features/bookings/lib/host-filtering/findQualifiedHostsWithDelegationCredentials.ts index 7cab725546..c53be6d3d7 100644 --- a/packages/features/bookings/lib/host-filtering/findQualifiedHostsWithDelegationCredentials.ts +++ b/packages/features/bookings/lib/host-filtering/findQualifiedHostsWithDelegationCredentials.ts @@ -61,9 +61,9 @@ const isWithinRRHostSubset = { if (rrHostSubsetIds.length === 0 || !rrHostSubsetEnabled || schedulingType !== SchedulingType.ROUND_ROBIN) { return true; @@ -72,12 +72,13 @@ const isWithinRRHostSubset = diff --git a/packages/features/bookings/lib/onBookingEvents/BookingEventHandlerService.ts b/packages/features/bookings/lib/onBookingEvents/BookingEventHandlerService.ts index 2124a1a23d..3db6423c5f 100644 --- a/packages/features/bookings/lib/onBookingEvents/BookingEventHandlerService.ts +++ b/packages/features/bookings/lib/onBookingEvents/BookingEventHandlerService.ts @@ -357,6 +357,52 @@ export class BookingEventHandlerService { }); } + 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, + }); + } + + 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, + }); + } + async onBulkBookingsRejected(params: { bookings: Array<{ bookingUid: string; diff --git a/packages/features/bookings/lib/service/RecurringBookingService.test.ts b/packages/features/bookings/lib/service/RecurringBookingService.test.ts index 32506333d4..a52a9f63d0 100644 --- a/packages/features/bookings/lib/service/RecurringBookingService.test.ts +++ b/packages/features/bookings/lib/service/RecurringBookingService.test.ts @@ -158,6 +158,7 @@ describe("handleNewRecurringBooking", () => { bookingMeta: { userId: -1, // Simulating anonymous user like in the API test }, + creationSource: "WEBAPP", }); expect(createdBookings.length).toBe(numOfSlotsToBeBooked); diff --git a/packages/features/bookings/lib/service/RecurringBookingService.ts b/packages/features/bookings/lib/service/RecurringBookingService.ts index 3a1cf759ba..0ae1e0b36b 100644 --- a/packages/features/bookings/lib/service/RecurringBookingService.ts +++ b/packages/features/bookings/lib/service/RecurringBookingService.ts @@ -1,19 +1,36 @@ 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 { safeStringify } from "@calcom/lib/safeStringify"; +import { + buildBookingCreatedAuditData, + buildBookingRescheduledAuditData, +} from "../handleNewBooking/buildBookingEventAuditData"; export type BookingHandlerInput = { bookingData: CreateRecurringBookingData; } & CreateBookingMeta; -export const handleNewRecurringBooking = async ( - input: BookingHandlerInput, - deps: IRecurringBookingServiceDependencies -): Promise => { +export const handleNewRecurringBooking = async function ( + this: RecurringBookingService, + { + input, + deps, + creationSource, + }: { + input: BookingHandlerInput; + deps: IRecurringBookingServiceDependencies; + creationSource: CreationSource; + } +): Promise { const data = input.bookingData; const { regularBookingService } = deps; const createdBookings: BookingResponse[] = []; @@ -121,11 +138,24 @@ export const handleNewRecurringBooking = async ( } } } + + if (createdBookings.length > 0) { + await this.fireBookingEvents({ + createdBookings, + eventTypeId: firstBooking.eventTypeId, + rescheduleUid: firstBooking.rescheduleUid ?? null, + actorUserUuid: input.userUuid ?? null, + rescheduledBy: firstBooking.rescheduledBy ?? null, + creationSource, + }); + } + return createdBookings; }; export interface IRecurringBookingServiceDependencies { regularBookingService: RegularBookingService; + bookingEventHandler: BookingEventHandlerService; } /** @@ -134,19 +164,144 @@ export interface IRecurringBookingServiceDependencies { export class RecurringBookingService implements IBookingService { constructor(private readonly deps: IRecurringBookingServiceDependencies) {} + async fireBookingEvents({ + createdBookings, + eventTypeId, + rescheduleUid, + actorUserUuid, + rescheduledBy, + creationSource, + }: { + createdBookings: BookingResponse[]; + eventTypeId: number; + rescheduleUid: string | null; + actorUserUuid: string | null; + rescheduledBy: string | null; + creationSource: CreationSource | undefined; + }) { + try { + type ValidBooking = BookingResponse & { + uid: string; + startTime: Date; + endTime: Date; + status: BookingStatus; + userUuid: string | null; + }; + type ValidRescheduledBooking = ValidBooking & { + previousBooking: ValidBooking & { status: BookingStatus }; + }; + + const isReschedule = !!rescheduleUid; + const firstCreatedBooking = createdBookings[0]; + const eventOrganizationId = firstCreatedBooking.organizationId; + const bookerAttendee = firstCreatedBooking.attendees?.[0]; + const bookerAttendeeId = bookerAttendee?.id; + const bookerName = bookerAttendee?.name || ""; + const bookerEmail = bookerAttendee?.email || ""; + + const rescheduledByAttendeeId = firstCreatedBooking.attendees?.find( + (attendee) => attendee.email === rescheduledBy + )?.id; + // TODO: Note that user.email is always null here as RegularBookingService intentionally sets it to null. To fix, we need to separate out external facing .createBooking and one that is used by RecurringBookingService, so that if we expose something there it doesn't get exposed elsewhere + const rescheduledByUserUuid = + firstCreatedBooking.user?.email === rescheduledBy ? firstCreatedBooking.userUuid : null; + + const auditActor = getBookingAuditActorForNewBooking({ + bookerAttendeeId: bookerAttendeeId ?? null, + actorUserUuid, + bookerEmail, + bookerName, + rescheduledBy: rescheduledBy + ? { + attendeeId: rescheduledByAttendeeId ?? null, + userUuid: rescheduledByUserUuid ?? null, + email: rescheduledBy, + } + : null, + logger: criticalLogger, + }); + + const actionSource = getAuditActionSource({ creationSource, eventTypeId, rescheduleUid }); + + const operationId = uuidv4(); + + const isValidBooking = (booking: BookingResponse): booking is ValidBooking => { + return !!(booking.uid && booking.startTime && booking.endTime && booking.status); + }; + + const isValidRescheduledBooking = (booking: BookingResponse): booking is ValidRescheduledBooking => { + return !!( + isValidBooking(booking) && + 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, attendeeSeatId: null }), + })); + + if (bulkCreatedBookings.length > 0) { + await this.deps.bookingEventHandler.onBulkBookingsCreated({ + bookings: bulkCreatedBookings, + actor: auditActor, + organizationId: eventOrganizationId, + operationId, + source: actionSource, + }); + } + } + } catch (error) { + criticalLogger.error("Error while firing booking events", safeStringify(error)); + } + } + async createBooking(input: { bookingData: CreateRecurringBookingData; bookingMeta?: CreateBookingMeta; + creationSource: CreationSource; }): Promise { const handlerInput = { bookingData: input.bookingData, ...(input.bookingMeta || {}) }; - return handleNewRecurringBooking(handlerInput, this.deps); + return handleNewRecurringBooking.bind(this)({ + input: handlerInput, + deps: this.deps, + creationSource: input.creationSource, + }); } async rescheduleBooking(input: { bookingData: CreateRecurringBookingData; bookingMeta?: CreateBookingMeta; + creationSource: CreationSource; }): Promise { const handlerInput = { bookingData: input.bookingData, ...(input.bookingMeta || {}) }; - return handleNewRecurringBooking(handlerInput, this.deps); + return handleNewRecurringBooking.bind(this)({ + input: handlerInput, + deps: this.deps, + creationSource: input.creationSource, + }); } } diff --git a/packages/features/bookings/lib/service/RegularBookingService.ts b/packages/features/bookings/lib/service/RegularBookingService.ts index d4d5cac860..3cc213627f 100644 --- a/packages/features/bookings/lib/service/RegularBookingService.ts +++ b/packages/features/bookings/lib/service/RegularBookingService.ts @@ -1,6 +1,11 @@ import short, { uuid } from "short-uuid"; import { v5 as uuidv5 } from "uuid"; - +import { getAuditActionSource } from "../handleNewBooking/getAuditActionSource"; +import { + buildBookingCreatedAuditData, + buildBookingRescheduledAuditData, +} from "../handleNewBooking/buildBookingEventAuditData"; +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,8 +132,8 @@ 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"; const translator = short(); @@ -175,6 +180,7 @@ export const buildDryRunBooking = ({ eventTypeId: number; organizerUser: { id: number; + uuid: string; name: string | null; username: string | null; email: string; @@ -190,6 +196,7 @@ export const buildDryRunBooking = ({ }) => { const sanitizedOrganizerUser = { id: organizerUser.id, + uuid: organizerUser.uuid, name: organizerUser.name, username: organizerUser.username, email: organizerUser.email, @@ -203,6 +210,7 @@ export const buildDryRunBooking = ({ eventTypeId: eventTypeId, user: sanitizedOrganizerUser, userId: sanitizedOrganizerUser.id, + userUuid: sanitizedOrganizerUser.uuid, title: eventName, startTime: new Date(startTime), endTime: new Date(endTime), @@ -418,6 +426,7 @@ function formatAvailabilitySnapshot(data: { function buildBookingCreatedPayload({ booking, organizerUserId, + organizerUserUuid, hashedLink, isDryRun, organizationId, @@ -431,6 +440,7 @@ function buildBookingCreatedPayload({ userId: number | null; }; organizerUserId: number; + organizerUserUuid: string | null; hashedLink: string | null; isDryRun: boolean; organizationId: number | null; @@ -449,6 +459,7 @@ function buildBookingCreatedPayload({ endTime: booking.endTime, status: booking.status, userId: booking.userId, + userUuid: organizerUserUuid, user: { id: organizerUserId, }, @@ -560,6 +571,7 @@ async function getEventOrganizationId({ } async function handler( + this: RegularBookingService, input: BookingHandlerInput, deps: IBookingServiceDependencies, bookingDataSchemaGetter: BookingDataSchemaGetter = getBookingDataSchema @@ -567,6 +579,7 @@ async function handler( const { bookingData: rawBookingData, userId, + userUuid, platformClientId, platformCancelUrl, platformBookingUrl, @@ -605,9 +618,9 @@ async function handler( userId: userId ?? null, eventType: eventType ? { - seatsPerTimeSlot: eventType.seatsPerTimeSlot, - minimumRescheduleNotice: eventType.minimumRescheduleNotice ?? null, - } + seatsPerTimeSlot: eventType.seatsPerTimeSlot, + minimumRescheduleNotice: eventType.minimumRescheduleNotice ?? null, + } : null, }); @@ -788,6 +801,14 @@ 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, }; } } @@ -1259,7 +1280,7 @@ async function handler( // If the team member is requested then they should be the organizer const organizerUser = reqBody.teamMemberEmail - ? users.find((user) => user.email === reqBody.teamMemberEmail) ?? users[0] + ? (users.find((user) => user.email === reqBody.teamMemberEmail) ?? users[0]) : users[0]; const tOrganizer = await getTranslation(organizerUser?.locale ?? "en", "common"); @@ -1377,9 +1398,9 @@ async function handler( // This ensures that createMeeting isn't called for static video apps as bookingLocation becomes just a regular value for them. const { bookingLocation, conferenceCredentialId } = organizerOrFirstDynamicGroupMemberDefaultLocationUrl ? { - bookingLocation: organizerOrFirstDynamicGroupMemberDefaultLocationUrl, - conferenceCredentialId: undefined, - } + bookingLocation: organizerOrFirstDynamicGroupMemberDefaultLocationUrl, + conferenceCredentialId: undefined, + } : getLocationValueForDB(locationBodyString, eventType.locations); tracingLogger.info("locationBodyString", locationBodyString); @@ -1502,7 +1523,7 @@ async function handler( platformBookingUrl, }) .withOrganization(organizerOrganizationId) - .withHashedLink(hasHashedBookingLink ? reqBody.hashedLink ?? null : null) + .withHashedLink(hasHashedBookingLink ? (reqBody.hashedLink ?? null) : null) .build(); if (!builtEvt) { @@ -1627,6 +1648,7 @@ async function handler( email: null, }, userId: null, + userUuid: null, title: eventName, startTime: new Date(reqBody.start), endTime: new Date(reqBody.end), @@ -1681,10 +1703,24 @@ 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 +1737,7 @@ async function handler( tAttendees, bookingSeat, reqUserId: input.userId, + reqUserUuid: userUuid, rescheduleReason, reqBodyUser: reqBody.user, noEmail, @@ -1721,7 +1758,10 @@ async function handler( workflows, rescheduledBy: reqBody.rescheduledBy, isDryRun, + organizationId: eventOrganizationId, + actionSource, traceContext, + deps, }); if (newBooking) { @@ -1738,6 +1778,14 @@ 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 @@ -2048,14 +2096,14 @@ async function handler( } const updateManager = !skipCalendarSyncTaskCreation ? await eventManager.reschedule( - evt, - originalRescheduledBooking.uid, - undefined, - changedOrganizer, - previousHostDestinationCalendar, - isBookingRequestedReschedule, - skipDeleteEventsAndMeetings - ) + evt, + originalRescheduledBooking.uid, + undefined, + changedOrganizer, + previousHostDestinationCalendar, + isBookingRequestedReschedule, + skipDeleteEventsAndMeetings + ) : placeholderCreatedEvent; // This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back // to the default description when we are sending the emails. @@ -2119,7 +2167,7 @@ async function handler( const googleHangoutLink = Array.isArray(googleCalResult?.updatedEvent) ? googleCalResult.updatedEvent[0]?.hangoutLink - : googleCalResult?.updatedEvent?.hangoutLink ?? googleCalResult?.createdEvent?.hangoutLink; + : (googleCalResult?.updatedEvent?.hangoutLink ?? googleCalResult?.createdEvent?.hangoutLink); if (googleHangoutLink) { results.push({ @@ -2149,7 +2197,7 @@ async function handler( } const createdOrUpdatedEvent = Array.isArray(results[0]?.updatedEvent) ? results[0]?.updatedEvent[0] - : results[0]?.updatedEvent ?? results[0]?.createdEvent; + : (results[0]?.updatedEvent ?? results[0]?.createdEvent); metadata.hangoutLink = createdOrUpdatedEvent?.hangoutLink; metadata.conferenceData = createdOrUpdatedEvent?.conferenceData; metadata.entryPoints = createdOrUpdatedEvent?.entryPoints; @@ -2356,68 +2404,31 @@ async function handler( const metadata = videoCallUrl ? { - videoCallUrl: getVideoCallUrlFromCalEvent(evt) || videoCallUrl, - } + videoCallUrl: getVideoCallUrlFromCalEvent(evt) || videoCallUrl, + } : undefined; - const bookingCreatedPayload = buildBookingCreatedPayload({ - booking, - organizerUserId: organizerUser.id, + await this.fireBookingEvents({ + booking: { + ...booking, + userEmail: booking.user?.email ?? null, + }, + organizerUser, // FIXME: It looks like hasHashedBookingLink is set to true based on the value of hashedLink when sending the request. So, technically we could remove hasHashedBookingLink usage completely - hashedLink: hasHashedBookingLink ? reqBody.hashedLink ?? null : null, + hashedLink: hasHashedBookingLink ? (reqBody.hashedLink ?? null) : null, isDryRun, - organizationId: eventOrganizationId, + eventOrganizationId, + bookerEmail, + bookerName: fullName, + actorUserUuid: userUuid ?? null, + originalRescheduledBooking, + rescheduledBy: reqBody.rescheduledBy ?? null, + actionSource, + isRecurringBooking: !!input.bookingData.allRecurringDates, + attendeeSeatId: evt.attendeeSeatId ?? null, + 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, @@ -2480,9 +2491,9 @@ async function handler( ...eventType, metadata: eventType.metadata ? { - ...eventType.metadata, - apps: eventType.metadata?.apps as Prisma.JsonValue, - } + ...eventType.metadata, + apps: eventType.metadata?.apps as Prisma.JsonValue, + } : {}, }, paymentAppCredentials: eventTypePaymentAppCredential as IEventTypePaymentCredentialType, @@ -2569,6 +2580,14 @@ 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 +2830,14 @@ 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, }; } @@ -2820,14 +2847,122 @@ async function handler( * We are open to renaming it to something more descriptive. */ export class RegularBookingService implements IBookingService { - constructor(private readonly deps: IBookingServiceDependencies) { } + constructor(private readonly deps: IBookingServiceDependencies) {} + + async fireBookingEvents({ + booking, + organizerUser, + hashedLink, + isDryRun, + eventOrganizationId, + bookerEmail, + bookerName, + actorUserUuid, + originalRescheduledBooking, + rescheduledBy, + actionSource, + isRecurringBooking, + attendeeSeatId, + tracingLogger, + }: { + booking: { + id: number; + uid: string; + startTime: Date; + endTime: Date; + status: BookingStatus; + userId: number | null; + attendees?: Array<{ id: number; email: string }>; + userUuid: string | null; + userEmail: string | null; + }; + organizerUser: { id: number; uuid: string }; + hashedLink: string | null; + isDryRun: boolean; + eventOrganizationId: number | null; + bookerEmail: string; + bookerName: string; + rescheduledBy: string | null; + actorUserUuid: string | null; + originalRescheduledBooking: BookingType | null; + actionSource: ActionSource; + isRecurringBooking: boolean; + tracingLogger: ReturnType; + attendeeSeatId: string | null; + }) { + try { + const bookingCreatedPayload = buildBookingCreatedPayload({ + booking, + organizerUserId: organizerUser.id, + organizerUserUuid: organizerUser.uuid, + hashedLink, + isDryRun, + organizationId: eventOrganizationId, + }); + + const bookingEventHandler = this.deps.bookingEventHandler; + const bookerAttendeeId = booking.attendees?.find((attendee) => attendee.email === bookerEmail)?.id; + const rescheduledByAttendeeId = booking.attendees?.find( + (attendee) => attendee.email === rescheduledBy + )?.id; + const rescheduledByUserUuid = booking.userEmail === rescheduledBy ? booking.userUuid : null; + const auditActor = getBookingAuditActorForNewBooking({ + bookerAttendeeId: bookerAttendeeId ?? null, + actorUserUuid, + bookerEmail, + bookerName, + rescheduledBy: rescheduledBy + ? { + attendeeId: rescheduledByAttendeeId ?? null, + userUuid: rescheduledByUserUuid ?? null, + email: rescheduledBy, + } + : null, + logger: tracingLogger, + }); + + // For recurring bookings we fire the events in the RecurringBookingService + if (!isRecurringBooking) { + if (originalRescheduledBooking) { + const bookingRescheduledPayload: BookingRescheduledPayload = { + ...bookingCreatedPayload, + oldBooking: { + uid: originalRescheduledBooking.uid, + startTime: originalRescheduledBooking.startTime, + endTime: originalRescheduledBooking.endTime, + }, + }; + await bookingEventHandler.onBookingRescheduled({ + payload: bookingRescheduledPayload, + actor: auditActor, + auditData: buildBookingRescheduledAuditData({ + oldBooking: originalRescheduledBooking, + newBooking: booking, + }), + source: actionSource, + operationId: null, + }); + } else { + await bookingEventHandler.onBookingCreated({ + payload: bookingCreatedPayload, + actor: auditActor, + auditData: buildBookingCreatedAuditData({ booking, attendeeSeatId }), + source: actionSource, + operationId: null, + }); + } + } + } catch (error) { + tracingLogger.error("Error while firing booking events", safeStringify(error)); + } + } 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 +2974,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/eventtypes/lib/defaultEvents.ts b/packages/features/eventtypes/lib/defaultEvents.ts index 8088b6e678..c8bc272ae9 100644 --- a/packages/features/eventtypes/lib/defaultEvents.ts +++ b/packages/features/eventtypes/lib/defaultEvents.ts @@ -38,6 +38,7 @@ const user: User & { credentials: CredentialPayload[] } = { bufferTime: 0, availability: [], id: 0, + uuid: "00000000-0000-0000-0000-000000000000", startTime: 0, endTime: 0, allSelectedCalendars: [], diff --git a/packages/features/users/lib/getRoutedUsers.ts b/packages/features/users/lib/getRoutedUsers.ts index 7f0efe135b..edf4e5a19a 100644 --- a/packages/features/users/lib/getRoutedUsers.ts +++ b/packages/features/users/lib/getRoutedUsers.ts @@ -65,6 +65,7 @@ async function findMatchingTeamMembersIdsForEventRRSegment(eventType: EventType) type BaseUser = { id: number; + uuid: string; email: string; }; diff --git a/packages/prisma/selects/user.ts b/packages/prisma/selects/user.ts index 6a698173e9..e4f109bfe3 100644 --- a/packages/prisma/selects/user.ts +++ b/packages/prisma/selects/user.ts @@ -2,6 +2,7 @@ import type { Prisma } from "../client"; export const availabilityUserSelect = { id: true, + uuid: true, timeZone: true, email: true, bufferTime: true,