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 <hariombalhara@gmail.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 <hariombalhara@gmail.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>
This commit is contained in:
Hariom Balhara
2026-01-13 13:16:16 +00:00
committed by GitHub
co-authored by Devin AI
parent 0408ce0741
commit 75d611c2e8
36 changed files with 1198 additions and 453 deletions
@@ -349,6 +349,7 @@ export class BookingsController_2024_04_15 {
platformBookingLocation: bookingRequest.platformBookingLocation,
noEmail: bookingRequest.body.noEmail,
},
creationSource: "API_V2",
});
createdBookings.forEach(async (booking) => {
@@ -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 || "" })),
@@ -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 { }
@@ -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,
});
}
}
+1 -18
View File
@@ -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");
+2 -2
View File
@@ -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);
+13 -6
View File
@@ -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}}</0>",
"rescheduled_from": "Rescheduled <0>{{oldDate}}</0> -> {{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}}</0>",
"assignment_type": "Assignment",
"assignment_type_manual": "Manual",
"assignment_type_round_robin": "Round robin",
"actor_impersonated_by": "Impersonator",
"source": "Source"
},
@@ -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<typeof CreatedAuditActionService.latestFieldsSchema, typeof CreatedAuditActionService.storedDataSchema>;
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<TranslationWithParams> {
return { key: "booking_audit_action.created" };
async getDisplayTitle({ storedData }: GetDisplayTitleParams): Promise<TranslationWithParams> {
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;
};
@@ -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,
}
];
@@ -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,
});
@@ -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,
});
@@ -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()],
@@ -201,6 +201,28 @@ export interface BookingAuditProducerService {
context?: BookingAuditContext;
}): Promise<void>;
queueBulkCreatedAudit(params: {
bookings: Array<{
bookingUid: string;
data: z.infer<typeof CreatedAuditActionService.latestFieldsSchema>;
}>;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
}): Promise<void>;
queueBulkRescheduledAudit(params: {
bookings: Array<{
bookingUid: string;
data: z.infer<typeof RescheduledAuditActionService.latestFieldsSchema>;
}>;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
}): Promise<void>;
queueBulkRejectedAudit(params: {
bookings: Array<{
bookingUid: string;
@@ -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;
@@ -402,6 +402,38 @@ export class BookingAuditTaskerProducerService implements BookingAuditProducerSe
});
}
async queueBulkCreatedAudit(params: {
bookings: Array<{
bookingUid: string;
data: z.infer<typeof CreatedAuditActionService.latestFieldsSchema>;
}>;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
}): Promise<void> {
await this.queueBulkTask({
...params,
action: CreatedAuditActionService.TYPE,
});
}
async queueBulkRescheduledAudit(params: {
bookings: Array<{
bookingUid: string;
data: z.infer<typeof RescheduledAuditActionService.latestFieldsSchema>;
}>;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
}): Promise<void> {
await this.queueBulkTask({
...params,
action: RescheduledAuditActionService.TYPE,
});
}
async queueBulkRejectedAudit(params: {
bookings: Array<{
bookingUid: string;
@@ -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);
}
}
@@ -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<string, MockUser>,
bookings: {} as Record<string, MockBooking>,
auditLogs: {} as Record<string, BookingAuditWithActor[]>,
attendees: {} as Record<number, MockAttendee>,
memberships: {} as Record<string, boolean>, // 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
});
});
});
@@ -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,
},
},
],
@@ -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,
},
});
+1
View File
@@ -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;
@@ -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,
},
};
};
@@ -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
@@ -19,6 +19,7 @@ const _findBookingQuery = async (bookingId: number) => {
metadata: true,
user: {
select: {
uuid: true,
name: true,
email: true,
timeZone: true,
@@ -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";
};
@@ -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 });
}
@@ -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<HandleSeatsResultBooking>;
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,
@@ -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 };
+14 -6
View File
@@ -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<Booking> & {
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<{
@@ -61,9 +61,9 @@ const isWithinRRHostSubset = <T extends { isFixed: boolean; user: { id: number }
rrHostSubsetEnabled,
schedulingType,
}: { rrHostSubsetEnabled: boolean; schedulingType?: SchedulingType } = {
rrHostSubsetEnabled: false,
schedulingType: undefined,
}
rrHostSubsetEnabled: false,
schedulingType: undefined,
}
): host is T & { isFixed: false } => {
if (rrHostSubsetIds.length === 0 || !rrHostSubsetEnabled || schedulingType !== SchedulingType.ROUND_ROBIN) {
return true;
@@ -72,12 +72,13 @@ const isWithinRRHostSubset = <T extends { isFixed: boolean; user: { id: number }
};
export class QualifiedHostsService {
constructor(public readonly dependencies: IQualifiedHostsService) {}
constructor(public readonly dependencies: IQualifiedHostsService) { }
async _findQualifiedHostsWithDelegationCredentials<
T extends {
email: string;
id: number;
uuid: string;
credentials: CredentialPayload[];
userLevelSelectedCalendars: SelectedCalendar[];
} & Record<string, unknown>
@@ -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;
@@ -158,6 +158,7 @@ describe("handleNewRecurringBooking", () => {
bookingMeta: {
userId: -1, // Simulating anonymous user like in the API test
},
creationSource: "WEBAPP",
});
expect(createdBookings.length).toBe(numOfSlotsToBeBooked);
@@ -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<BookingResponse[]> => {
export const handleNewRecurringBooking = async function (
this: RecurringBookingService,
{
input,
deps,
creationSource,
}: {
input: BookingHandlerInput;
deps: IRecurringBookingServiceDependencies;
creationSource: CreationSource;
}
): Promise<BookingResponse[]> {
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<BookingResponse[]> {
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<BookingResponse[]> {
const handlerInput = { bookingData: input.bookingData, ...(input.bookingMeta || {}) };
return handleNewRecurringBooking(handlerInput, this.deps);
return handleNewRecurringBooking.bind(this)({
input: handlerInput,
deps: this.deps,
creationSource: input.creationSource,
});
}
}
@@ -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<typeof distributedTracing.getTracingLogger>;
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,
@@ -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: [],
@@ -65,6 +65,7 @@ async function findMatchingTeamMembersIdsForEventRRSegment(eventType: EventType)
type BaseUser = {
id: number;
uuid: string;
email: string;
};
+1
View File
@@ -2,6 +2,7 @@ import type { Prisma } from "../client";
export const availabilityUserSelect = {
id: true,
uuid: true,
timeZone: true,
email: true,
bufferTime: true,