diff --git a/apps/web/pages/api/book/event.ts b/apps/web/pages/api/book/event.ts
index 63b6e679e7..894fc8fb92 100644
--- a/apps/web/pages/api/book/event.ts
+++ b/apps/web/pages/api/book/event.ts
@@ -54,6 +54,7 @@ async function handler(req: NextApiRequest & { userId?: number; traceContext: Tr
hostname: req.headers.host || "",
forcedSlug: req.headers["x-cal-force-slug"] as string | undefined,
traceContext: req.traceContext,
+ impersonatedByUserUuid: session?.user?.impersonatedBy?.uuid,
},
});
diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json
index 6c3adf50db..61a63f6938 100644
--- a/apps/web/public/static/locales/en/common.json
+++ b/apps/web/public/static/locales/en/common.json
@@ -4164,7 +4164,9 @@
"attendee_no_show_updated": "Attendee No-Show Updated",
"type": "Assignment Type",
"assignmentType_manual": "Manual Assignment",
- "assignmentType_roundRobin": "Round Robin Assignment"
+ "assignmentType_roundRobin": "Round Robin Assignment",
+ "actor_impersonated_by": "Impersonator",
+ "source": "Source"
},
"error_loading_booking_logs": "Error loading booking logs",
"no_audit_logs_found": "No audit logs found",
diff --git a/packages/features/auth/lib/getServerSession.ts b/packages/features/auth/lib/getServerSession.ts
index 69fd6b063b..a06eab9ca4 100644
--- a/packages/features/auth/lib/getServerSession.ts
+++ b/packages/features/auth/lib/getServerSession.ts
@@ -124,12 +124,14 @@ export async function getServerSession(options: {
},
select: {
id: true,
+ uuid: true,
role: true,
},
});
if (impersonatedByUser) {
session.user.impersonatedBy = {
id: impersonatedByUser?.id,
+ uuid: impersonatedByUser.uuid,
role: impersonatedByUser.role,
};
}
diff --git a/packages/features/booking-audit/client/components/BookingHistory.tsx b/packages/features/booking-audit/client/components/BookingHistory.tsx
index 9c487a2c59..1ba2a0677e 100644
--- a/packages/features/booking-audit/client/components/BookingHistory.tsx
+++ b/packages/features/booking-audit/client/components/BookingHistory.tsx
@@ -43,6 +43,11 @@ type AuditLog = {
displayEmail: string | null;
displayAvatar: string | null;
};
+ impersonatedBy?: {
+ displayName: string;
+ displayEmail: string | null;
+ displayAvatar: string | null;
+ } | null;
};
interface BookingLogsFiltersProps {
@@ -283,8 +288,14 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
{t("actor")}
{log.actor.displayName || log.actor.type}
+ {log.impersonatedBy && (
+
+ {t("booking_audit_action.actor_impersonated_by", { actor: log.actor.displayName })}
+ {log.impersonatedBy.displayName}
+
+ )}
- {t("source")}
+ {t("booking_audit_action.source")}
{log.source}
diff --git a/packages/features/booking-audit/lib/dto/types.ts b/packages/features/booking-audit/lib/dto/types.ts
new file mode 100644
index 0000000000..794e57edc5
--- /dev/null
+++ b/packages/features/booking-audit/lib/dto/types.ts
@@ -0,0 +1,70 @@
+import { z } from "zod";
+
+const UserActorSchema = z.object({
+ identifiedBy: z.literal("user"),
+ userUuid: z.string(),
+});
+
+const AttendeeActorSchema = z.object({
+ identifiedBy: z.literal("attendee"),
+ attendeeId: z.number(),
+});
+
+const ActorByIdSchema = z.object({
+ identifiedBy: z.literal("id"),
+ id: z.string(),
+});
+
+const GuestActorSchema = z.object({
+ identifiedBy: z.literal("guest"),
+ email: z.string(),
+ name: z.string().nullable(),
+});
+
+const AppActorByCredentialIdSchema = z.object({
+ identifiedBy: z.literal("app"),
+ credentialId: z.number(),
+});
+
+const AppActorBySlugSchema = z.object({
+ identifiedBy: z.literal("appSlug"),
+ appSlug: z.string(),
+ name: z.string(),
+});
+
+export const ActorSchema = z.discriminatedUnion("identifiedBy", [
+ ActorByIdSchema,
+ UserActorSchema,
+ AttendeeActorSchema,
+ GuestActorSchema,
+ AppActorByCredentialIdSchema,
+ AppActorBySlugSchema,
+]);
+
+export const PiiFreeActorSchema = z.discriminatedUnion("identifiedBy", [
+ ActorByIdSchema,
+ UserActorSchema,
+ AttendeeActorSchema,
+]);
+
+export type Actor = z.infer
;
+export type PiiFreeActor = z.infer;
+
+export type UserActor = z.infer;
+export type GuestActor = z.infer;
+export type AttendeeActor = z.infer;
+export type ActorById = z.infer;
+export type AppActorByCredentialId = z.infer;
+export type AppActorBySlug = z.infer;
+
+/**
+ * Schema for booking audit context - Records things that are common across all actions but could be useful to capture when the action is performed.
+ * e.g. impersonation, userAgent, ip etc.
+ * This is separate from action-specific data because impersonation is orthogonal to the action type
+ */
+export const BookingAuditContextSchema = z.object({
+ impersonatedBy: z.string().optional(),
+});
+
+export type BookingAuditContext = z.infer;
+
diff --git a/packages/features/booking-audit/lib/makeActor.ts b/packages/features/booking-audit/lib/makeActor.ts
new file mode 100644
index 0000000000..c77c8c7a13
--- /dev/null
+++ b/packages/features/booking-audit/lib/makeActor.ts
@@ -0,0 +1,83 @@
+import type { UserActor, GuestActor, AttendeeActor, ActorById, AppActorByCredentialId, AppActorBySlug } from "./dto/types";
+
+const SYSTEM_ACTOR_ID = "00000000-0000-0000-0000-000000000000";
+
+/**
+ * Creates an Actor representing a User by UUID
+ */
+export function makeUserActor(userUuid: string): UserActor {
+ return {
+ identifiedBy: "user",
+ userUuid,
+ };
+}
+
+export function makeGuestActor({ email, name }: { email: string, name: string | null }): GuestActor {
+ return {
+ identifiedBy: "guest",
+ email,
+ name: name ?? null,
+ };
+}
+
+/**
+ * Creates an Actor representing the System
+ * System actors must be referenced by ID (requires migration)
+ */
+export function makeSystemActor(): ActorById {
+ return {
+ identifiedBy: "id",
+ id: SYSTEM_ACTOR_ID,
+ };
+}
+
+
+/**
+ * Creates an Actor by existing actor ID
+ */
+export function makeActorById(id: string): ActorById {
+ return {
+ identifiedBy: "id",
+ id,
+ };
+}
+
+/**
+ * Creates an Actor representing an Attendee by attendee ID
+ */
+export function makeAttendeeActor(attendeeId: number): AttendeeActor {
+ return {
+ identifiedBy: "attendee",
+ attendeeId,
+ };
+}
+
+/**
+ * Creates an Actor representing an App by credential ID (preferred)
+ * The credentialId uniquely identifies which app instance (e.g., which Stripe account)
+ * App name and slug are derived from the credential at display time
+ */
+export function makeAppActor(params: { credentialId: number }): AppActorByCredentialId {
+ return {
+ identifiedBy: "app",
+ credentialId: params.credentialId,
+ };
+}
+
+/**
+ * Creates an Actor representing an App by app slug (fallback)
+ * Used when credentialId is not available or for apps not yet migrated
+ * App actors use @app.internal email convention
+ */
+export function makeAppActorUsingSlug(params: { appSlug: string; name: string }): AppActorBySlug {
+ return {
+ identifiedBy: "appSlug",
+ appSlug: params.appSlug,
+ name: params.name,
+ };
+}
+
+export function buildActorEmail({ identifier, actorType }: { identifier: string, actorType: "system" | "guest" | "app" }): string {
+ return `${identifier}@${actorType}.internal`;
+}
+
diff --git a/packages/features/booking-audit/lib/repository/IBookingAuditRepository.ts b/packages/features/booking-audit/lib/repository/IBookingAuditRepository.ts
index 8bd67a3024..e8cc8bd2fd 100644
--- a/packages/features/booking-audit/lib/repository/IBookingAuditRepository.ts
+++ b/packages/features/booking-audit/lib/repository/IBookingAuditRepository.ts
@@ -1,6 +1,7 @@
import type { JsonValue } from "@calcom/types/Json";
import type { AuditActorType } from "./IAuditActorRepository";
import type { ActionSource } from "../types/actionSource";
+import type { BookingAuditContext } from "../dto/types";
export type BookingAuditType = "RECORD_CREATED" | "RECORD_UPDATED" | "RECORD_DELETED"
@@ -21,6 +22,7 @@ export type BookingAuditCreateInput = {
timestamp: Date;
source: ActionSource;
operationId: string;
+ context?: BookingAuditContext;
}
type BookingAudit = {
@@ -38,6 +40,7 @@ type BookingAudit = {
}
export type BookingAuditWithActor = BookingAudit & {
+ context: BookingAuditContext | null;
actor: {
id: string;
type: AuditActorType;
diff --git a/packages/features/booking-audit/lib/repository/PrismaBookingAuditRepository.ts b/packages/features/booking-audit/lib/repository/PrismaBookingAuditRepository.ts
index fac6111eb0..f037c756af 100644
--- a/packages/features/booking-audit/lib/repository/PrismaBookingAuditRepository.ts
+++ b/packages/features/booking-audit/lib/repository/PrismaBookingAuditRepository.ts
@@ -1,6 +1,8 @@
import type { PrismaClient } from "@calcom/prisma";
+import type { Prisma } from "@calcom/prisma/client";
import type { IBookingAuditRepository, BookingAuditCreateInput, BookingAuditWithActor } from "./IBookingAuditRepository";
+import { BookingAuditContextSchema } from "../dto/types";
type Dependencies = {
prismaClient: PrismaClient;
@@ -30,6 +32,7 @@ const safeBookingAuditSelect = {
source: true,
operationId: true,
data: true,
+ context: true,
createdAt: true,
updatedAt: true,
} as const;
@@ -37,8 +40,15 @@ const safeBookingAuditSelect = {
export class PrismaBookingAuditRepository implements IBookingAuditRepository {
constructor(private readonly deps: Dependencies) { }
+ private parsed(auditLog: T) {
+ return {
+ ...auditLog,
+ context: auditLog.context ? BookingAuditContextSchema.parse(auditLog.context) : null,
+ };
+ }
+
async create(bookingAudit: BookingAuditCreateInput) {
- return this.deps.prismaClient.bookingAudit.create({
+ const created = await this.deps.prismaClient.bookingAudit.create({
data: {
bookingUid: bookingAudit.bookingUid,
actorId: bookingAudit.actorId,
@@ -48,8 +58,11 @@ export class PrismaBookingAuditRepository implements IBookingAuditRepository {
source: bookingAudit.source,
operationId: bookingAudit.operationId,
data: bookingAudit.data === null ? undefined : bookingAudit.data,
+ context: bookingAudit.context ?? undefined,
},
});
+
+ return this.parsed(created);
}
async createMany(bookingAudits: BookingAuditCreateInput[]) {
@@ -63,13 +76,14 @@ export class PrismaBookingAuditRepository implements IBookingAuditRepository {
source: bookingAudit.source,
operationId: bookingAudit.operationId,
data: bookingAudit.data === null ? undefined : bookingAudit.data,
+ context: bookingAudit.context === undefined ? undefined : bookingAudit.context,
})),
});
return { count: result.count };
}
async findAllForBooking(bookingUid: string): Promise {
- return this.deps.prismaClient.bookingAudit.findMany({
+ const results = await this.deps.prismaClient.bookingAudit.findMany({
where: {
bookingUid,
},
@@ -83,10 +97,12 @@ export class PrismaBookingAuditRepository implements IBookingAuditRepository {
timestamp: "desc",
},
});
+
+ return results.map(this.parsed);
}
async findRescheduledLogsOfBooking(bookingUid: string): Promise {
- return this.deps.prismaClient.bookingAudit.findMany({
+ const results = await this.deps.prismaClient.bookingAudit.findMany({
where: {
bookingUid,
action: "RESCHEDULED",
@@ -99,6 +115,8 @@ export class PrismaBookingAuditRepository implements IBookingAuditRepository {
},
orderBy: { timestamp: "desc" },
});
+
+ return results.map(this.parsed);
}
}
diff --git a/packages/features/booking-audit/lib/service/BookingAuditProducerService.interface.ts b/packages/features/booking-audit/lib/service/BookingAuditProducerService.interface.ts
index d54a5bd7eb..cb9927a24e 100644
--- a/packages/features/booking-audit/lib/service/BookingAuditProducerService.interface.ts
+++ b/packages/features/booking-audit/lib/service/BookingAuditProducerService.interface.ts
@@ -1,6 +1,6 @@
import { z } from "zod";
-import type { Actor } from "../../../bookings/lib/types/actor";
+import type { Actor, BookingAuditContext } from "../dto/types";
import type { ActionSource } from "../types/actionSource";
import { AcceptedAuditActionService } from "../actions/AcceptedAuditActionService";
import { AttendeeAddedAuditActionService } from "../actions/AttendeeAddedAuditActionService";
@@ -38,6 +38,7 @@ export interface BookingAuditProducerService {
source: ActionSource;
operationId?: string | null;
data: z.infer;
+ context?: BookingAuditContext;
}): Promise;
queueRescheduledAudit(params: {
@@ -47,6 +48,7 @@ export interface BookingAuditProducerService {
source: ActionSource;
operationId?: string | null;
data: z.infer;
+ context?: BookingAuditContext;
}): Promise;
queueAcceptedAudit(params: {
@@ -56,6 +58,7 @@ export interface BookingAuditProducerService {
source: ActionSource;
operationId?: string | null;
data: z.infer;
+ context?: BookingAuditContext;
}): Promise;
queueCancelledAudit(params: {
@@ -65,6 +68,7 @@ export interface BookingAuditProducerService {
source: ActionSource;
operationId?: string | null;
data: z.infer;
+ context?: BookingAuditContext;
}): Promise;
queueRescheduleRequestedAudit(params: {
@@ -74,6 +78,7 @@ export interface BookingAuditProducerService {
source: ActionSource;
operationId?: string | null;
data: z.infer;
+ context?: BookingAuditContext;
}): Promise;
queueAttendeeAddedAudit(params: {
@@ -83,6 +88,7 @@ export interface BookingAuditProducerService {
source: ActionSource;
operationId?: string | null;
data: z.infer;
+ context?: BookingAuditContext;
}): Promise;
queueHostNoShowUpdatedAudit(params: {
@@ -92,6 +98,7 @@ export interface BookingAuditProducerService {
source: ActionSource;
operationId?: string | null;
data: z.infer;
+ context?: BookingAuditContext;
}): Promise;
queueRejectedAudit(params: {
@@ -101,6 +108,7 @@ export interface BookingAuditProducerService {
source: ActionSource;
operationId?: string | null;
data: z.infer;
+ context?: BookingAuditContext;
}): Promise;
queueAttendeeRemovedAudit(params: {
@@ -110,6 +118,7 @@ export interface BookingAuditProducerService {
source: ActionSource;
operationId?: string | null;
data: z.infer;
+ context?: BookingAuditContext;
}): Promise;
queueReassignmentAudit(params: {
@@ -119,6 +128,7 @@ export interface BookingAuditProducerService {
source: ActionSource;
operationId?: string | null;
data: z.infer;
+ context?: BookingAuditContext;
}): Promise;
queueLocationChangedAudit(params: {
@@ -128,6 +138,7 @@ export interface BookingAuditProducerService {
source: ActionSource;
operationId?: string | null;
data: z.infer;
+ context?: BookingAuditContext;
}): Promise;
queueAttendeeNoShowUpdatedAudit(params: {
@@ -137,6 +148,7 @@ export interface BookingAuditProducerService {
source: ActionSource;
operationId?: string | null;
data: z.infer;
+ context?: BookingAuditContext;
}): Promise;
queueSeatBookedAudit(params: {
@@ -146,6 +158,7 @@ export interface BookingAuditProducerService {
source: ActionSource;
operationId?: string | null;
data: z.infer;
+ context?: BookingAuditContext;
}): Promise;
queueSeatRescheduledAudit(params: {
@@ -155,6 +168,7 @@ export interface BookingAuditProducerService {
source: ActionSource;
operationId?: string | null;
data: z.infer;
+ context?: BookingAuditContext;
}): Promise;
/**
@@ -169,6 +183,7 @@ export interface BookingAuditProducerService {
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
+ context?: BookingAuditContext;
}): Promise;
/**
@@ -183,6 +198,7 @@ export interface BookingAuditProducerService {
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
+ context?: BookingAuditContext;
}): Promise;
}
diff --git a/packages/features/booking-audit/lib/service/BookingAuditTaskConsumer.ts b/packages/features/booking-audit/lib/service/BookingAuditTaskConsumer.ts
index f0417c9409..66440d3ade 100644
--- a/packages/features/booking-audit/lib/service/BookingAuditTaskConsumer.ts
+++ b/packages/features/booking-audit/lib/service/BookingAuditTaskConsumer.ts
@@ -4,7 +4,7 @@ import logger from "@calcom/lib/logger";
import type { IFeaturesRepository } from "@calcom/features/flags/features.repository.interface";
import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
-import type { PiiFreeActor } from "../../../bookings/lib/types/actor";
+import type { PiiFreeActor, BookingAuditContext } from "../dto/types";
import type {
SingleBookingAuditTaskConsumerPayload,
BulkBookingAuditTaskConsumerPayload,
@@ -33,6 +33,7 @@ type CreateBookingAuditInput = {
operationId: string;
data: JsonValue;
timestamp: Date; // Required: actual time of the booking change (business event)
+ context?: BookingAuditContext;
};
type BookingAudit = {
@@ -84,7 +85,7 @@ export class BookingAuditTaskConsumer {
* Process single booking Audit Task
*/
async processAuditTask(payload: SingleBookingAuditTaskConsumerPayload, taskId: string): Promise {
- const { action, bookingUid, actor, organizationId, data, timestamp, source, operationId } = payload;
+ const { action, bookingUid, actor, organizationId, data, timestamp, source, operationId, context } = payload;
if (!await this.shouldProcessAudit({
organizationId,
@@ -96,14 +97,14 @@ export class BookingAuditTaskConsumer {
const dataInLatestFormat = await this.migrateIfNeeded({ action, data, payload, taskId });
- await this.onBookingAction({ bookingUid, actor, action, source, operationId, data: dataInLatestFormat, timestamp });
+ await this.onBookingAction({ bookingUid, actor, action, source, operationId, data: dataInLatestFormat, timestamp, context });
}
/**
* Process Bulk bookings Audit Task
*/
async processBulkAuditTask(payload: BulkBookingAuditTaskConsumerPayload, taskId: string): Promise {
- const { bookings, action, actor, organizationId, timestamp, source, operationId } = payload;
+ const { bookings, action, actor, organizationId, timestamp, source, operationId, context } = payload;
if (!await this.shouldProcessAudit({
organizationId,
@@ -127,6 +128,7 @@ export class BookingAuditTaskConsumer {
source,
operationId,
timestamp,
+ context,
});
}
@@ -319,6 +321,7 @@ export class BookingAuditTaskConsumer {
action: input.action,
source: input.source,
timestamp: input.timestamp,
+ context: input.context,
}));
return this.bookingAuditRepository.create({
@@ -330,6 +333,7 @@ export class BookingAuditTaskConsumer {
timestamp: input.timestamp,
operationId: input.operationId,
data: input.data ?? null,
+ context: input.context,
});
}
@@ -382,8 +386,9 @@ export class BookingAuditTaskConsumer {
source: ActionSource;
operationId: string;
timestamp: number;
+ context?: BookingAuditContext;
}): Promise {
- const { bookings, actor, action, source, operationId, timestamp } = params;
+ const { bookings, actor, action, source, operationId, timestamp, context } = params;
const actorId = await this.resolveActorId(actor);
const recordType = this.getRecordType({ action });
@@ -400,6 +405,7 @@ export class BookingAuditTaskConsumer {
operationId,
data: versionedData as JsonValue,
timestamp: new Date(timestamp),
+ context,
};
});
@@ -418,8 +424,9 @@ export class BookingAuditTaskConsumer {
operationId: string;
data: Record;
timestamp: number;
+ context?: BookingAuditContext;
}): Promise {
- const { bookingUid, actor, action, source, operationId, data, timestamp } = params;
+ const { bookingUid, actor, action, source, operationId, data, timestamp, context } = params;
const actionService = this.actionServiceRegistry.getActionService(action);
const versionedData = actionService.getVersionedData(data);
const actorId = await this.resolveActorId(actor);
@@ -435,6 +442,7 @@ export class BookingAuditTaskConsumer {
// versionedData is { version: number; fields: unknown } which is JsonValue-compatible
data: versionedData as JsonValue,
timestamp: new Date(timestamp),
+ context,
});
}
}
diff --git a/packages/features/booking-audit/lib/service/BookingAuditTaskerProducerService.ts b/packages/features/booking-audit/lib/service/BookingAuditTaskerProducerService.ts
index 03cfb41863..5821bab4f1 100644
--- a/packages/features/booking-audit/lib/service/BookingAuditTaskerProducerService.ts
+++ b/packages/features/booking-audit/lib/service/BookingAuditTaskerProducerService.ts
@@ -7,7 +7,8 @@ import type { ISimpleLogger } from "@calcom/features/di/shared/services/logger.s
import type { BookingAuditAction } from "../types/bookingAuditTask";
import type { ActionSource } from "../types/actionSource";
-import { makeActorById, type PiiFreeActor, type Actor, buildActorEmail } from "../../../bookings/lib/types/actor";
+import type { PiiFreeActor, Actor, BookingAuditContext } from "../dto/types";
+import { makeActorById, buildActorEmail } from "../makeActor";
import type { IAuditActorRepository } from "../repository/IAuditActorRepository";
import { AcceptedAuditActionService } from "../actions/AcceptedAuditActionService";
import { AttendeeAddedAuditActionService } from "../actions/AttendeeAddedAuditActionService";
@@ -98,6 +99,7 @@ export class BookingAuditTaskerProducerService implements BookingAuditProducerSe
source: ActionSource;
operationId?: string | null;
data: unknown;
+ context?: BookingAuditContext;
}): Promise {
// Skip queueing for non-organization bookings
if (params.organizationId === null) {
@@ -123,6 +125,7 @@ export class BookingAuditTaskerProducerService implements BookingAuditProducerSe
source: params.source,
operationId,
data: params.data,
+ context: params.context,
});
} catch (error) {
this.log.error(`Error while queueing ${params.action} audit`, safeStringify(error));
@@ -335,6 +338,7 @@ export class BookingAuditTaskerProducerService implements BookingAuditProducerSe
action: string;
source: ActionSource;
operationId?: string | null;
+ context?: BookingAuditContext;
}): Promise {
// Skip queueing for non-organization bookings
if (params.organizationId === null) {
@@ -359,6 +363,7 @@ export class BookingAuditTaskerProducerService implements BookingAuditProducerSe
action: params.action as BookingAuditAction,
source: params.source,
operationId,
+ context: params.context,
});
} catch (error) {
this.log.error(`Error while queueing bulk ${params.action} audit`, safeStringify(error));
diff --git a/packages/features/booking-audit/lib/service/BookingAuditViewerService.ts b/packages/features/booking-audit/lib/service/BookingAuditViewerService.ts
index e480b2345e..cb7f3dfafd 100644
--- a/packages/features/booking-audit/lib/service/BookingAuditViewerService.ts
+++ b/packages/features/booking-audit/lib/service/BookingAuditViewerService.ts
@@ -12,6 +12,7 @@ import type { TranslationWithParams } from "../actions/IAuditActionService";
import type { ActionSource } from "../types/actionSource";
import { RescheduledAuditActionService } from "../actions/RescheduledAuditActionService";
import { getAppNameFromSlug } from "../getAppNameFromSlug";
+import type { BookingAuditContext } from "../dto/types";
interface BookingAuditViewerServiceDeps {
bookingAuditRepository: IBookingAuditRepository;
@@ -46,6 +47,11 @@ type EnrichedAuditLog = {
displayEmail: string | null;
displayAvatar: string | null;
};
+ impersonatedBy?: {
+ displayName: string;
+ displayEmail: string | null;
+ displayAvatar: string | null;
+ } | null;
};
export type DisplayBookingAuditLog = EnrichedAuditLog;
@@ -150,6 +156,8 @@ export class BookingAuditViewerService {
? actionService.getDisplayFields(parsedData)
: null;
+ const impersonatedBy = await this.enrichImpersonator(log.context);
+
return {
id: log.id,
bookingUid: log.bookingUid,
@@ -173,6 +181,7 @@ export class BookingAuditViewerService {
displayEmail: enrichedActor.displayEmail,
displayAvatar: enrichedActor.displayAvatar,
},
+ impersonatedBy,
};
}
/**
@@ -231,6 +240,31 @@ export class BookingAuditViewerService {
};
}
+ private async enrichImpersonator(context: BookingAuditContext | null): Promise<{
+ displayName: string;
+ displayEmail: string | null;
+ displayAvatar: string | null;
+ } | null> {
+ if (!context?.impersonatedBy) {
+ return null;
+ }
+
+ const impersonatorUser = await this.userRepository.findByUuid({ uuid: context.impersonatedBy });
+ if (!impersonatorUser) {
+ return {
+ displayName: "Deleted User",
+ displayEmail: null,
+ displayAvatar: null,
+ };
+ }
+
+ return {
+ displayName: impersonatorUser.name || impersonatorUser.email,
+ displayEmail: impersonatorUser.email,
+ displayAvatar: impersonatorUser.avatarUrl || null,
+ };
+ }
+
/**
* Enrich actor information with user details if userUuid exists
*/
diff --git a/packages/features/booking-audit/lib/service/__tests__/BookingAuditViewerService.test.ts b/packages/features/booking-audit/lib/service/__tests__/BookingAuditViewerService.test.ts
index 50d6be6cca..5b29a95e23 100644
--- a/packages/features/booking-audit/lib/service/__tests__/BookingAuditViewerService.test.ts
+++ b/packages/features/booking-audit/lib/service/__tests__/BookingAuditViewerService.test.ts
@@ -4,6 +4,7 @@ import { PermissionCheckService } from "@calcom/features/pbac/services/permissio
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
+import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import type { IAttendeeRepository } from "@calcom/features/bookings/repositories/IAttendeeRepository";
import type { ISimpleLogger } from "@calcom/features/di/shared/services/logger.service";
@@ -11,11 +12,13 @@ import { BookingAuditViewerService } from "../BookingAuditViewerService";
import { BookingAuditPermissionError, BookingAuditErrorCode } from "../BookingAuditAccessService";
import type { IBookingAuditRepository, BookingAuditWithActor, BookingAuditAction, BookingAuditType } from "../../repository/IBookingAuditRepository";
import type { AuditActorType } from "../../repository/IAuditActorRepository";
+import type { BookingAuditContext } from "../../dto/types";
vi.mock("@calcom/features/pbac/services/permission-check.service");
vi.mock("@calcom/features/users/repositories/UserRepository");
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;
@@ -51,6 +54,7 @@ const createMockAuditLog = (
actorType: AuditActorType;
actorUserUuid: string | null;
actorName: string | null;
+ context: BookingAuditContext | null;
}>
): BookingAuditWithActor => ({
id: overrides?.id ?? "audit-log-1",
@@ -64,11 +68,13 @@ const createMockAuditLog = (
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"),
},
@@ -105,6 +111,9 @@ describe("BookingAuditViewerService - Integration Tests", () => {
let mockAttendeeRepository: {
findById: Mock;
};
+ let mockCredentialRepository: {
+ findByCredentialId: Mock;
+ };
let mockLog: {
error: Mock;
};
@@ -140,14 +149,19 @@ describe("BookingAuditViewerService - Integration Tests", () => {
findById: vi.fn(),
};
+ mockCredentialRepository = {
+ findByCredentialId: vi.fn(),
+ };
+
mockLog = {
error: vi.fn(),
};
- vi.mocked(BookingRepository).mockImplementation(function() { return mockBookingRepository as unknown as BookingRepository; });
- vi.mocked(UserRepository).mockImplementation(function() { return mockUserRepository as unknown as UserRepository; });
- vi.mocked(MembershipRepository).mockImplementation(function() { return mockMembershipRepository as unknown as MembershipRepository; });
- vi.mocked(PermissionCheckService).mockImplementation(function() { return mockPermissionCheckService as unknown as PermissionCheckService; });
+ vi.mocked(BookingRepository).mockImplementation(function () { return mockBookingRepository as unknown as BookingRepository; });
+ vi.mocked(UserRepository).mockImplementation(function () { return mockUserRepository as unknown as UserRepository; });
+ vi.mocked(MembershipRepository).mockImplementation(function () { return mockMembershipRepository as unknown as MembershipRepository; });
+ vi.mocked(PermissionCheckService).mockImplementation(function () { return mockPermissionCheckService as unknown as PermissionCheckService; });
+ vi.mocked(CredentialRepository).mockImplementation(function () { return mockCredentialRepository as unknown as CredentialRepository; });
service = new BookingAuditViewerService({
bookingAuditRepository: mockBookingAuditRepository as unknown as IBookingAuditRepository,
@@ -155,6 +169,7 @@ describe("BookingAuditViewerService - Integration Tests", () => {
bookingRepository: mockBookingRepository as unknown as BookingRepository,
membershipRepository: mockMembershipRepository as unknown as MembershipRepository,
attendeeRepository: mockAttendeeRepository as unknown as IAttendeeRepository,
+ credentialRepository: mockCredentialRepository as unknown as CredentialRepository,
log: mockLog as unknown as ISimpleLogger,
});
});
@@ -888,5 +903,142 @@ describe("BookingAuditViewerService - Integration Tests", () => {
expect(result.auditLogs[0].actionDisplayTitle.components).toBeUndefined();
});
});
+
+ describe("when handling impersonatedBy context", () => {
+ beforeEach(() => {
+ mockBookingRepository.findByUidIncludeEventType.mockResolvedValue(
+ createMockTeamBooking({ teamId: 100 })
+ );
+ mockPermissionCheckService.checkPermission.mockResolvedValue(true);
+ });
+
+ it("should enrich impersonatedBy when user exists", async () => {
+ const mockLog = createMockAuditLog({
+ context: { impersonatedBy: "impersonator-uuid-456" },
+ });
+ const impersonatorUser = createMockUser({
+ 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
+
+ const result = await service.getAuditLogsForBooking({
+ bookingUid: "booking-uid-123",
+ userId: 123,
+ userEmail: "user@example.com",
+ userTimeZone: "UTC",
+ organizationId: 200,
+ });
+
+ expect(mockUserRepository.findByUuid).toHaveBeenCalledWith({ uuid: "impersonator-uuid-456" });
+ expect(result.auditLogs[0].impersonatedBy).toMatchObject({
+ displayName: "Admin User",
+ displayEmail: "admin@example.com",
+ displayAvatar: "https://example.com/admin-avatar.jpg",
+ });
+ });
+
+ it("should use email as displayName when impersonator name is null", async () => {
+ const mockLog = createMockAuditLog({
+ context: { impersonatedBy: "impersonator-uuid-456" },
+ });
+ const impersonatorUser = createMockUser({
+ 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
+
+ const result = await service.getAuditLogsForBooking({
+ bookingUid: "booking-uid-123",
+ userId: 123,
+ userEmail: "user@example.com",
+ userTimeZone: "UTC",
+ organizationId: 200,
+ });
+
+ expect(result.auditLogs[0].impersonatedBy).toMatchObject({
+ displayName: "admin@example.com",
+ displayEmail: "admin@example.com",
+ displayAvatar: null,
+ });
+ });
+
+ it("should show 'Deleted User' when impersonator user not found", async () => {
+ const mockLog = createMockAuditLog({
+ context: { impersonatedBy: "impersonator-uuid-456" },
+ });
+
+ mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
+ mockUserRepository.findByUuid.mockResolvedValueOnce(createMockUser()); // For actor
+ mockUserRepository.findByUuid.mockResolvedValueOnce(null); // For impersonator (not found)
+
+ const result = await service.getAuditLogsForBooking({
+ bookingUid: "booking-uid-123",
+ userId: 123,
+ userEmail: "user@example.com",
+ userTimeZone: "UTC",
+ organizationId: 200,
+ });
+
+ expect(mockUserRepository.findByUuid).toHaveBeenCalledWith({ uuid: "impersonator-uuid-456" });
+ expect(result.auditLogs[0].impersonatedBy).toMatchObject({
+ displayName: "Deleted User",
+ displayEmail: null,
+ displayAvatar: null,
+ });
+ });
+
+ it("should return null when context is null", async () => {
+ const mockLog = createMockAuditLog({
+ context: null,
+ });
+
+ mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
+ mockUserRepository.findByUuid.mockResolvedValue(createMockUser());
+
+ const result = await service.getAuditLogsForBooking({
+ bookingUid: "booking-uid-123",
+ userId: 123,
+ userEmail: "user@example.com",
+ userTimeZone: "UTC",
+ organizationId: 200,
+ });
+
+ 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({
+ context: {},
+ });
+
+ mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
+ mockUserRepository.findByUuid.mockResolvedValue(createMockUser());
+
+ const result = await service.getAuditLogsForBooking({
+ bookingUid: "booking-uid-123",
+ userId: 123,
+ userEmail: "user@example.com",
+ userTimeZone: "UTC",
+ organizationId: 200,
+ });
+
+ expect(result.auditLogs[0].impersonatedBy).toBeNull();
+ // Should not call findByUuid for impersonator when impersonatedBy is not in context
+ expect(mockUserRepository.findByUuid).toHaveBeenCalledTimes(1); // Only for actor
+ });
+ });
});
});
diff --git a/packages/features/booking-audit/lib/service/booking-audit.integration-test.ts b/packages/features/booking-audit/lib/service/booking-audit.integration-test.ts
index c79e99b30a..ecaa883522 100644
--- a/packages/features/booking-audit/lib/service/booking-audit.integration-test.ts
+++ b/packages/features/booking-audit/lib/service/booking-audit.integration-test.ts
@@ -5,7 +5,7 @@ import { BookingStatus, MembershipRole } from "@calcom/prisma/enums";
import type { BookingAuditTaskConsumer } from "./BookingAuditTaskConsumer";
import type { BookingAuditViewerService } from "./BookingAuditViewerService";
-import { makeUserActor } from "../../../bookings/lib/types/actor";
+import { makeUserActor } from "../makeActor";
import { getBookingAuditTaskConsumer } from "../../di/BookingAuditTaskConsumer.container";
import { getBookingAuditViewerService } from "../../di/BookingAuditViewerService.container";
@@ -338,6 +338,48 @@ describe("Booking Audit Integration", () => {
expect(auditLog.actor.userUuid).toBe(testData.owner.uuid);
});
+ it("should include impersonator details when context has impersonatedBy", async () => {
+ // Create a second user to act as impersonator
+ const impersonator = await createTestUser({ name: "Admin Impersonator" });
+
+ const actor = makeUserActor(testData.owner.uuid);
+
+ await bookingAuditTaskConsumer.onBookingAction({
+ bookingUid: testData.booking.uid,
+ actor,
+ action: "CREATED",
+ source: "WEBAPP",
+ operationId: `op-${Date.now()}`,
+ data: {
+ startTime: testData.booking.startTime.getTime(),
+ endTime: testData.booking.endTime.getTime(),
+ status: testData.booking.status,
+ },
+ timestamp: Date.now(),
+ context: {
+ impersonatedBy: impersonator.uuid,
+ },
+ });
+
+ const result = await bookingAuditViewerService.getAuditLogsForBooking({
+ bookingUid: testData.booking.uid,
+ userId: testData.owner.id,
+ userEmail: testData.owner.email,
+ userTimeZone: "UTC",
+ organizationId: testData.organization.id,
+ });
+
+ expect(result.auditLogs).toHaveLength(1);
+
+ const auditLog = result.auditLogs[0];
+ expect(auditLog.impersonatedBy).toBeDefined();
+ expect(auditLog.impersonatedBy?.displayName).toBe("Admin Impersonator");
+ expect(auditLog.impersonatedBy?.displayEmail).toBe(impersonator.email);
+
+ // Cleanup impersonator user
+ await prisma.user.delete({ where: { id: impersonator.id } });
+ });
+
it.skip("should deny access to unauthorized users viewing audit logs", async () => {
const actor = makeUserActor(testData.owner.uuid);
@@ -407,6 +449,7 @@ describe("Booking Audit Integration", () => {
await bookingAuditTaskConsumer.processBulkAuditTask(
{
+ isBulk: true,
bookings: [
{
bookingUid: testData.booking.uid,
diff --git a/packages/features/booking-audit/lib/types/bookingAuditTask.ts b/packages/features/booking-audit/lib/types/bookingAuditTask.ts
index d98849e452..7324196250 100644
--- a/packages/features/booking-audit/lib/types/bookingAuditTask.ts
+++ b/packages/features/booking-audit/lib/types/bookingAuditTask.ts
@@ -1,6 +1,6 @@
import { z } from "zod";
-import { PiiFreeActorSchema } from "../../../bookings/lib/types/actor";
+import { PiiFreeActorSchema, BookingAuditContextSchema } from "../dto/types";
import { ActionSourceSchema } from "./actionSource";
/**
@@ -42,6 +42,7 @@ export const SingleBookingAuditTaskConsumerSchema = z.object({
action: BookingAuditActionSchema,
source: ActionSourceSchema.default("UNKNOWN"),
operationId: z.string(),
+ context: BookingAuditContextSchema.optional(),
});
export type SingleBookingAuditTaskConsumerPayload = z.infer;
@@ -62,6 +63,7 @@ export const BulkBookingAuditTaskConsumerSchema = z.object({
action: BookingAuditActionSchema,
source: ActionSourceSchema.default("UNKNOWN"),
operationId: z.string(),
+ context: BookingAuditContextSchema.optional(),
});
export type BulkBookingAuditTaskConsumerPayload = z.infer;
diff --git a/packages/features/bookings/lib/dto/types.d.ts b/packages/features/bookings/lib/dto/types.d.ts
index 75e6efc10b..47161ca3e7 100644
--- a/packages/features/bookings/lib/dto/types.d.ts
+++ b/packages/features/bookings/lib/dto/types.d.ts
@@ -39,6 +39,7 @@ export type CreateBookingMeta = {
forcedSlug?: string;
noEmail?: boolean;
traceContext?: TraceContext;
+ impersonatedByUserUuid?: string;
} & PlatformParams;
export type BookingHandlerInput = {
diff --git a/packages/features/bookings/lib/onBookingEvents/BookingEventHandlerService.ts b/packages/features/bookings/lib/onBookingEvents/BookingEventHandlerService.ts
index 9fb2666e14..ee8974b017 100644
--- a/packages/features/bookings/lib/onBookingEvents/BookingEventHandlerService.ts
+++ b/packages/features/bookings/lib/onBookingEvents/BookingEventHandlerService.ts
@@ -17,7 +17,7 @@ import type { ActionSource } from "@calcom/features/booking-audit/lib/types/acti
import type { HashedLinkService } from "@calcom/features/hashedLink/lib/service/HashedLinkService";
import type { ISimpleLogger } from "@calcom/features/di/shared/services/logger.service";
import { safeStringify } from "@calcom/lib/safeStringify";
-import type { Actor } from "../types/actor";
+import type { Actor, BookingAuditContext } from "@calcom/features/booking-audit/lib/dto/types";
import type { BookingCreatedPayload, BookingRescheduledPayload } from "./types";
interface BookingEventHandlerDeps {
@@ -32,6 +32,7 @@ interface OnBookingCreatedParams {
auditData: CreatedAuditData;
source: ActionSource;
operationId?: string | null;
+ context?: BookingAuditContext;
}
interface OnBookingRescheduledParams {
@@ -40,6 +41,7 @@ interface OnBookingRescheduledParams {
auditData: RescheduledAuditData;
source: ActionSource;
operationId?: string | null;
+ context?: BookingAuditContext;
}
interface BaseBookingEventParams {
@@ -49,6 +51,7 @@ interface BaseBookingEventParams {
auditData: TAuditData;
source: ActionSource;
operationId?: string | null;
+ context?: BookingAuditContext;
}
type OnBookingAcceptedParams = BaseBookingEventParams;
@@ -74,7 +77,7 @@ export class BookingEventHandlerService {
}
async onBookingCreated(params: OnBookingCreatedParams) {
- const { payload, actor, auditData, source, operationId } = params;
+ const { payload, actor, auditData, source, operationId, context } = params;
this.log.debug("onBookingCreated", safeStringify(payload));
if (payload.config.isDryRun) {
return;
@@ -87,11 +90,12 @@ export class BookingEventHandlerService {
source,
operationId,
data: auditData,
+ context,
});
}
async onBookingRescheduled(params: OnBookingRescheduledParams) {
- const { payload, actor, auditData, source, operationId } = params;
+ const { payload, actor, auditData, source, operationId, context } = params;
this.log.debug("onBookingRescheduled", safeStringify(payload));
if (payload.config.isDryRun) {
return;
@@ -106,6 +110,7 @@ export class BookingEventHandlerService {
source,
operationId,
data: auditData,
+ context,
});
}
@@ -139,7 +144,7 @@ export class BookingEventHandlerService {
}
async onBookingAccepted(params: OnBookingAcceptedParams) {
- const { bookingUid, actor, organizationId, auditData, source, operationId } = params;
+ const { bookingUid, actor, organizationId, auditData, source, operationId, context } = params;
await this.bookingAuditProducerService.queueAcceptedAudit({
bookingUid,
actor,
@@ -147,11 +152,12 @@ export class BookingEventHandlerService {
source,
operationId,
data: auditData,
+ context,
});
}
async onBookingCancelled(params: OnBookingCancelledParams) {
- const { bookingUid, actor, organizationId, auditData, source, operationId } = params;
+ const { bookingUid, actor, organizationId, auditData, source, operationId, context } = params;
await this.bookingAuditProducerService.queueCancelledAudit({
bookingUid,
actor,
@@ -159,11 +165,12 @@ export class BookingEventHandlerService {
source,
operationId,
data: auditData,
+ context,
});
}
async onRescheduleRequested(params: OnRescheduleRequestedParams) {
- const { bookingUid, actor, organizationId, auditData, source, operationId } = params;
+ const { bookingUid, actor, organizationId, auditData, source, operationId, context } = params;
await this.bookingAuditProducerService.queueRescheduleRequestedAudit({
bookingUid,
actor,
@@ -171,11 +178,12 @@ export class BookingEventHandlerService {
source,
operationId,
data: auditData,
+ context,
});
}
async onAttendeeAdded(params: OnAttendeeAddedParams) {
- const { bookingUid, actor, organizationId, auditData, source, operationId } = params;
+ const { bookingUid, actor, organizationId, auditData, source, operationId, context } = params;
await this.bookingAuditProducerService.queueAttendeeAddedAudit({
bookingUid,
actor,
@@ -183,11 +191,12 @@ export class BookingEventHandlerService {
source,
operationId,
data: auditData,
+ context,
});
}
async onHostNoShowUpdated(params: OnHostNoShowUpdatedParams) {
- const { bookingUid, actor, organizationId, auditData, source, operationId } = params;
+ const { bookingUid, actor, organizationId, auditData, source, operationId, context } = params;
await this.bookingAuditProducerService.queueHostNoShowUpdatedAudit({
bookingUid,
actor,
@@ -195,11 +204,12 @@ export class BookingEventHandlerService {
source,
operationId,
data: auditData,
+ context,
});
}
async onBookingRejected(params: OnBookingRejectedParams) {
- const { bookingUid, actor, organizationId, auditData, source, operationId } = params;
+ const { bookingUid, actor, organizationId, auditData, source, operationId, context } = params;
await this.bookingAuditProducerService.queueRejectedAudit({
bookingUid,
actor,
@@ -207,11 +217,12 @@ export class BookingEventHandlerService {
source,
operationId,
data: auditData,
+ context,
});
}
async onAttendeeRemoved(params: OnAttendeeRemovedParams) {
- const { bookingUid, actor, organizationId, auditData, source, operationId } = params;
+ const { bookingUid, actor, organizationId, auditData, source, operationId, context } = params;
await this.bookingAuditProducerService.queueAttendeeRemovedAudit({
bookingUid,
actor,
@@ -219,11 +230,12 @@ export class BookingEventHandlerService {
source,
operationId,
data: auditData,
+ context,
});
}
async onReassignment(params: OnReassignmentParams) {
- const { bookingUid, actor, organizationId, auditData, source, operationId } = params;
+ const { bookingUid, actor, organizationId, auditData, source, operationId, context } = params;
await this.bookingAuditProducerService.queueReassignmentAudit({
bookingUid,
actor,
@@ -231,11 +243,12 @@ export class BookingEventHandlerService {
source,
operationId,
data: auditData,
+ context,
});
}
async onLocationChanged(params: OnLocationChangedParams) {
- const { bookingUid, actor, organizationId, auditData, source, operationId } = params;
+ const { bookingUid, actor, organizationId, auditData, source, operationId, context } = params;
await this.bookingAuditProducerService.queueLocationChangedAudit({
bookingUid,
actor,
@@ -243,11 +256,12 @@ export class BookingEventHandlerService {
source,
operationId,
data: auditData,
+ context,
});
}
async onAttendeeNoShowUpdated(params: OnAttendeeNoShowUpdatedParams) {
- const { bookingUid, actor, organizationId, auditData, source, operationId } = params;
+ const { bookingUid, actor, organizationId, auditData, source, operationId, context } = params;
await this.bookingAuditProducerService.queueAttendeeNoShowUpdatedAudit({
bookingUid,
actor,
@@ -255,11 +269,12 @@ export class BookingEventHandlerService {
source,
operationId,
data: auditData,
+ context,
});
}
async onSeatBooked(params: OnSeatBookedParams) {
- const { bookingUid, actor, organizationId, auditData, source, operationId } = params;
+ const { bookingUid, actor, organizationId, auditData, source, operationId, context } = params;
await this.bookingAuditProducerService.queueSeatBookedAudit({
bookingUid,
actor,
@@ -267,11 +282,12 @@ export class BookingEventHandlerService {
source,
operationId,
data: auditData,
+ context,
});
}
async onSeatRescheduled(params: OnSeatRescheduledParams) {
- const { bookingUid, actor, organizationId, auditData, source, operationId } = params;
+ const { bookingUid, actor, organizationId, auditData, source, operationId, context } = params;
await this.bookingAuditProducerService.queueSeatRescheduledAudit({
bookingUid,
actor,
@@ -279,6 +295,7 @@ export class BookingEventHandlerService {
source,
operationId,
data: auditData,
+ context,
});
}
@@ -295,8 +312,9 @@ export class BookingEventHandlerService {
organizationId: number | null;
operationId?: string | null;
source: ActionSource;
+ context?: BookingAuditContext;
}) {
- const { bookings, actor, organizationId, operationId, source } = params;
+ const { bookings, actor, organizationId, operationId, source, context } = params;
await this.bookingAuditProducerService.queueBulkAcceptedAudit({
bookings: bookings.map((booking) => ({
bookingUid: booking.bookingUid,
@@ -306,6 +324,7 @@ export class BookingEventHandlerService {
organizationId,
source,
operationId,
+ context,
});
}
@@ -322,8 +341,9 @@ export class BookingEventHandlerService {
organizationId: number | null;
operationId?: string | null;
source: ActionSource;
+ context?: BookingAuditContext;
}) {
- const { bookings, actor, organizationId, operationId, source } = params;
+ const { bookings, actor, organizationId, operationId, source, context } = params;
await this.bookingAuditProducerService.queueBulkCancelledAudit({
bookings: bookings.map((booking) => ({
bookingUid: booking.bookingUid,
@@ -333,6 +353,7 @@ export class BookingEventHandlerService {
organizationId,
source,
operationId,
+ context,
});
}
}
diff --git a/packages/features/bookings/lib/service/RegularBookingService.ts b/packages/features/bookings/lib/service/RegularBookingService.ts
index 12696f00fa..79f608adb1 100644
--- a/packages/features/bookings/lib/service/RegularBookingService.ts
+++ b/packages/features/bookings/lib/service/RegularBookingService.ts
@@ -128,7 +128,7 @@ import { validateEventLength } from "../handleNewBooking/validateEventLength";
import handleSeats from "../handleSeats/handleSeats";
import type { IBookingService } from "../interfaces/IBookingService";
import { isWithinMinimumRescheduleNotice } from "../reschedule/isWithinMinimumRescheduleNotice";
-import { makeGuestActor } from "../types/actor";
+import { makeGuestActor } from "@calcom/features/booking-audit/lib/makeActor";
const translator = short();
diff --git a/packages/features/bookings/lib/types/actor.ts b/packages/features/bookings/lib/types/actor.ts
deleted file mode 100644
index 0f613c4b14..0000000000
--- a/packages/features/bookings/lib/types/actor.ts
+++ /dev/null
@@ -1,138 +0,0 @@
-import { z } from "zod";
-
-const UserActorSchema = z.object({
- identifiedBy: z.literal("user"),
- userUuid: z.string(),
-});
-
-const AttendeeActorSchema = z.object({
- identifiedBy: z.literal("attendee"),
- attendeeId: z.number(),
-});
-
-const ActorByIdSchema = z.object({
- identifiedBy: z.literal("id"),
- id: z.string(),
-});
-
-const GuestActorSchema = z.object({
- identifiedBy: z.literal("guest"),
- email: z.string(),
- name: z.string().nullable(),
-});
-
-const AppActorByCredentialIdSchema = z.object({
- identifiedBy: z.literal("app"),
- credentialId: z.number(),
-});
-
-const AppActorBySlugSchema = z.object({
- identifiedBy: z.literal("appSlug"),
- appSlug: z.string(),
- name: z.string(),
-});
-
-export const ActorSchema = z.discriminatedUnion("identifiedBy", [
- ActorByIdSchema,
- UserActorSchema,
- AttendeeActorSchema,
- GuestActorSchema,
- AppActorByCredentialIdSchema,
- AppActorBySlugSchema,
-]);
-
-export const PiiFreeActorSchema = z.discriminatedUnion("identifiedBy", [
- ActorByIdSchema,
- UserActorSchema,
- AttendeeActorSchema,
-]);
-
-export type Actor = z.infer;
-export type PiiFreeActor = z.infer;
-
-type UserActor = z.infer;
-type GuestActor = z.infer;
-type AttendeeActor = z.infer;
-type ActorById = z.infer;
-type AppActorByCredentialId = z.infer;
-type AppActorBySlug = z.infer;
-/**
- * Creates an Actor representing a User by UUID
- */
-export function makeUserActor(userUuid: string): UserActor {
- return {
- identifiedBy: "user",
- userUuid,
- };
-}
-
-export function makeGuestActor({ email, name }: { email: string, name: string | null }): GuestActor {
- return {
- identifiedBy: "guest",
- email,
- name: name ?? null,
- };
-}
-
-/**
- * Creates an Actor representing the System
- * System actors must be referenced by ID (requires migration)
- */
-export function makeSystemActor(): ActorById {
- return {
- identifiedBy: "id",
- id: SYSTEM_ACTOR_ID,
- };
-}
-
-
-/**
- * Creates an Actor by existing actor ID
- */
-export function makeActorById(id: string): ActorById {
- return {
- identifiedBy: "id",
- id,
- };
-}
-
-/**
- * Creates an Actor representing an Attendee by attendee ID
- */
-export function makeAttendeeActor(attendeeId: number): AttendeeActor {
- return {
- identifiedBy: "attendee",
- attendeeId,
- };
-}
-
-/**
- * Creates an Actor representing an App by credential ID (preferred)
- * The credentialId uniquely identifies which app instance (e.g., which Stripe account)
- * App name and slug are derived from the credential at display time
- */
-export function makeAppActor(params: { credentialId: number }): AppActorByCredentialId {
- return {
- identifiedBy: "app",
- credentialId: params.credentialId,
- };
-}
-
-/**
- * Creates an Actor representing an App by app slug (fallback)
- * Used when credentialId is not available or for apps not yet migrated
- * App actors use @app.internal email convention
- */
-export function makeAppActorUsingSlug(params: { appSlug: string; name: string }): AppActorBySlug {
- return {
- identifiedBy: "appSlug",
- appSlug: params.appSlug,
- name: params.name,
- };
-}
-
-export function buildActorEmail({ identifier, actorType }: { identifier: string, actorType: "system" | "guest" | "app" }): string {
- return `${identifier}@${actorType}.internal`;
-}
-
-export const SYSTEM_ACTOR_ID = "00000000-0000-0000-0000-000000000000";
\ No newline at end of file
diff --git a/packages/features/ee/impersonation/lib/ImpersonationProvider.ts b/packages/features/ee/impersonation/lib/ImpersonationProvider.ts
index 2f601252b4..c095c0e3cd 100644
--- a/packages/features/ee/impersonation/lib/ImpersonationProvider.ts
+++ b/packages/features/ee/impersonation/lib/ImpersonationProvider.ts
@@ -87,6 +87,7 @@ const auditAndReturnNextUser = async (
},
select: {
id: true,
+ uuid: true,
role: true,
},
});
@@ -95,8 +96,9 @@ const auditAndReturnNextUser = async (
return {
...obj,
impersonatedBy: {
- id: impersonatedByUser?.id,
- role: impersonatedByUser?.role,
+ id: impersonatedByUser.id,
+ uuid: impersonatedByUser.uuid,
+ role: impersonatedByUser.role,
},
};
}
diff --git a/packages/prisma/migrations/20251224092336_add_context_audit_action/migration.sql b/packages/prisma/migrations/20251224092336_add_context_audit_action/migration.sql
new file mode 100644
index 0000000000..4693eae322
--- /dev/null
+++ b/packages/prisma/migrations/20251224092336_add_context_audit_action/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "public"."BookingAudit" ADD COLUMN "context" JSONB;
diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma
index 92db9d2f90..5a27213308 100644
--- a/packages/prisma/schema.prisma
+++ b/packages/prisma/schema.prisma
@@ -2813,6 +2813,11 @@ model BookingAudit {
data Json?
+ // Context for across action types concerns like impersonation, ip, userAgent etc.
+ // This is separate from `data` because it isn't related to action type but makes sense with every action
+ // BookingAuditContextSchema
+ context Json?
+
@@index([actorId])
@@index([bookingUid])
@@index([timestamp])
diff --git a/packages/types/next-auth.d.ts b/packages/types/next-auth.d.ts
index faaff924ae..3df6fc68eb 100644
--- a/packages/types/next-auth.d.ts
+++ b/packages/types/next-auth.d.ts
@@ -24,6 +24,7 @@ declare module "next-auth" {
completedOnboarding?: boolean;
impersonatedBy?: {
id: number;
+ uuid: string;
role: PrismaUser["role"];
};
belongsToActiveTeam?: boolean;
@@ -58,6 +59,7 @@ declare module "next-auth/jwt" {
role?: UserPermissionRole | "INACTIVE_ADMIN" | null;
impersonatedBy?: {
id: number;
+ uuid: string;
role: PrismaUser["role"];
};
belongsToActiveTeam?: boolean;