+
diff --git a/packages/features/booking-audit/lib/service/BookingAuditViewerService.ts b/packages/features/booking-audit/lib/service/BookingAuditViewerService.ts
index de077da8e0..4b43fd792f 100644
--- a/packages/features/booking-audit/lib/service/BookingAuditViewerService.ts
+++ b/packages/features/booking-audit/lib/service/BookingAuditViewerService.ts
@@ -52,6 +52,7 @@ type EnrichedAuditLog = {
displayEmail: string | null;
displayAvatar: string | null;
} | null;
+ hasError?: boolean;
};
export type DisplayBookingAuditLog = EnrichedAuditLog;
@@ -112,7 +113,14 @@ export class BookingAuditViewerService {
const auditLogs = await this.bookingAuditRepository.findAllForBooking(bookingUid);
const enrichedAuditLogs = await Promise.all(
- auditLogs.map((log) => this.enrichAuditLog(log, userTimeZone))
+ auditLogs.map(async (log) => {
+ try {
+ return await this.enrichAuditLog(log, userTimeZone);
+ } catch (error) {
+ this.log.error(`Failed to enrich audit log ${log.id}: ${error instanceof Error ? error.message : String(error)}`);
+ return this.buildFallbackAuditLog(log);
+ }
+ })
);
const fromRescheduleUid = await this.bookingRepository.getFromRescheduleUid(bookingUid);
@@ -182,6 +190,40 @@ export class BookingAuditViewerService {
impersonatedBy,
};
}
+
+ /**
+ * Builds a minimal fallback audit log when enrichment fails.
+ * Returns a log entry with hasError: true and basic information from the raw log.
+ */
+ private buildFallbackAuditLog(log: BookingAuditWithActor): EnrichedAuditLog {
+ return {
+ id: log.id,
+ bookingUid: log.bookingUid,
+ type: log.type,
+ action: log.action,
+ timestamp: log.timestamp.toISOString(),
+ createdAt: log.createdAt.toISOString(),
+ source: log.source,
+ operationId: log.operationId,
+ displayJson: null,
+ actionDisplayTitle: { key: "booking_audit_action.error_processing", params: { actionType: log.action } },
+ displayFields: null,
+ actor: {
+ id: log.actor.id,
+ type: log.actor.type,
+ userUuid: log.actor.userUuid,
+ attendeeId: log.actor.attendeeId,
+ name: log.actor.name,
+ createdAt: log.actor.createdAt,
+ displayName: log.actor.name || "Unknown",
+ displayEmail: null,
+ displayAvatar: null,
+ },
+ impersonatedBy: null,
+ hasError: true,
+ };
+ }
+
/**
* Builds a "rescheduled from" log entry for bookings created from a reschedule.
* Fetches the RESCHEDULED log from the previous booking and transforms it
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 08661076ea..af26010da8 100644
--- a/packages/features/booking-audit/lib/service/__tests__/BookingAuditViewerService.test.ts
+++ b/packages/features/booking-audit/lib/service/__tests__/BookingAuditViewerService.test.ts
@@ -1,23 +1,21 @@
-import { describe, it, expect, beforeEach, vi, type Mock } from "vitest";
-
+import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
+import type { IAttendeeRepository } from "@calcom/features/bookings/repositories/IAttendeeRepository";
+import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
+import type { ISimpleLogger } from "@calcom/features/di/shared/services/logger.service";
+import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
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";
-
-import { BookingAuditViewerService } from "../BookingAuditViewerService";
-import { BookingAuditPermissionError, BookingAuditErrorCode } from "../BookingAuditAccessService";
+import { beforeEach, describe, expect, it, type Mock, vi } from "vitest";
+import type { BookingAuditContext } from "../../dto/types";
+import type { AuditActorType } from "../../repository/IAuditActorRepository";
import type {
- IBookingAuditRepository,
- BookingAuditWithActor,
BookingAuditAction,
BookingAuditType,
+ BookingAuditWithActor,
+ IBookingAuditRepository,
} from "../../repository/IBookingAuditRepository";
-import type { AuditActorType } from "../../repository/IAuditActorRepository";
-import type { BookingAuditContext } from "../../dto/types";
+import { BookingAuditErrorCode, BookingAuditPermissionError } from "../BookingAuditAccessService";
+import { BookingAuditViewerService } from "../BookingAuditViewerService";
vi.mock("@calcom/features/pbac/services/permission-check.service");
vi.mock("@calcom/features/users/repositories/UserRepository");
@@ -1097,5 +1095,203 @@ describe("BookingAuditViewerService - Integration Tests", () => {
expect(result.auditLogs[0].impersonatedBy).toBeNull();
});
});
+
+ describe("when enrichment fails for a single audit log", () => {
+ beforeEach(() => {
+ createMockTeamBooking("booking-uid-123", { teamId: 100 });
+ mockPermissionCheckService.checkPermission.mockResolvedValue(true);
+ });
+
+ it("should return fallback log with hasError when enrichActorInformation throws for USER actor without userUuid", async () => {
+ createMockAuditLog("booking-uid-123", {
+ id: "failing-log",
+ actorType: "USER",
+ actorUserUuid: null,
+ actorName: "Test Actor",
+ });
+
+ const result = await service.getAuditLogsForBooking({
+ bookingUid: "booking-uid-123",
+ userId: 123,
+ userEmail: "user@example.com",
+ userTimeZone: "UTC",
+ organizationId: 200,
+ });
+
+ expect(result.auditLogs).toHaveLength(1);
+ expect(result.auditLogs[0].hasError).toBe(true);
+ expect(result.auditLogs[0].actionDisplayTitle).toEqual({
+ key: "booking_audit_action.error_processing",
+ params: { actionType: "CREATED" },
+ });
+ expect(result.auditLogs[0].displayJson).toBeNull();
+ expect(result.auditLogs[0].displayFields).toBeNull();
+ expect(result.auditLogs[0].actor.displayName).toBe("Test Actor");
+ expect(mockLog.error).toHaveBeenCalledWith(
+ expect.stringContaining("Failed to enrich audit log failing-log")
+ );
+ });
+
+ it("should return fallback log with hasError when enrichActorInformation throws for ATTENDEE actor without attendeeId", async () => {
+ createMockAuditLog("booking-uid-123", {
+ id: "failing-attendee-log",
+ actorType: "ATTENDEE",
+ actorUserUuid: null,
+ actorAttendeeId: null,
+ actorName: null,
+ });
+
+ const result = await service.getAuditLogsForBooking({
+ bookingUid: "booking-uid-123",
+ userId: 123,
+ userEmail: "user@example.com",
+ userTimeZone: "UTC",
+ organizationId: 200,
+ });
+
+ expect(result.auditLogs).toHaveLength(1);
+ expect(result.auditLogs[0].hasError).toBe(true);
+ expect(result.auditLogs[0].actionDisplayTitle).toEqual({
+ key: "booking_audit_action.error_processing",
+ params: { actionType: "CREATED" },
+ });
+ expect(result.auditLogs[0].actor.displayName).toBe("Unknown");
+ expect(mockLog.error).toHaveBeenCalledWith(
+ expect.stringContaining("Failed to enrich audit log failing-attendee-log")
+ );
+ });
+
+ it("should still return other logs when one log fails to enrich", async () => {
+ createMockAuditLog("booking-uid-123", {
+ id: "successful-log",
+ action: "CREATED",
+ actorType: "USER",
+ actorUserUuid: "user-uuid-123",
+ });
+ createMockAuditLog("booking-uid-123", {
+ id: "failing-log",
+ action: "ACCEPTED",
+ actorType: "USER",
+ actorUserUuid: null,
+ actorName: "Failing Actor",
+ });
+ createMockAuditLog("booking-uid-123", {
+ id: "another-successful-log",
+ action: "CANCELLED",
+ actorType: "SYSTEM",
+ actorUserUuid: null,
+ data: {
+ version: 1,
+ fields: {
+ status: { old: "ACCEPTED", new: "CANCELLED" },
+ cancellationReason: "Test",
+ cancelledBy: "user-123",
+ },
+ },
+ });
+
+ createMockUser("user-uuid-123");
+
+ const result = await service.getAuditLogsForBooking({
+ bookingUid: "booking-uid-123",
+ userId: 123,
+ userEmail: "user@example.com",
+ userTimeZone: "UTC",
+ organizationId: 200,
+ });
+
+ expect(result.auditLogs).toHaveLength(3);
+
+ expect(result.auditLogs[0].id).toBe("successful-log");
+ expect(result.auditLogs[0].hasError).toBeUndefined();
+
+ expect(result.auditLogs[1].id).toBe("failing-log");
+ expect(result.auditLogs[1].hasError).toBe(true);
+ expect(result.auditLogs[1].actor.displayName).toBe("Failing Actor");
+
+ expect(result.auditLogs[2].id).toBe("another-successful-log");
+ expect(result.auditLogs[2].hasError).toBeUndefined();
+ expect(result.auditLogs[2].actor.displayName).toBe("Cal.com");
+ });
+
+ it("should log error message when enrichment fails", async () => {
+ createMockAuditLog("booking-uid-123", {
+ id: "error-log-id",
+ actorType: "USER",
+ actorUserUuid: null,
+ });
+
+ await service.getAuditLogsForBooking({
+ bookingUid: "booking-uid-123",
+ userId: 123,
+ userEmail: "user@example.com",
+ userTimeZone: "UTC",
+ organizationId: 200,
+ });
+
+ expect(mockLog.error).toHaveBeenCalledWith(
+ expect.stringMatching(/Failed to enrich audit log error-log-id: .+/)
+ );
+ });
+
+ it("should preserve basic log information in fallback", async () => {
+ const timestamp = new Date("2024-01-15T10:00:00Z");
+ const createdAt = new Date("2024-01-15T09:00:00Z");
+
+ createMockAuditLog("booking-uid-123", {
+ id: "fallback-test-log",
+ action: "CREATED",
+ type: "RECORD_CREATED",
+ timestamp,
+ createdAt,
+ actorType: "USER",
+ actorUserUuid: null,
+ actorName: "Test Name",
+ });
+
+ const result = await service.getAuditLogsForBooking({
+ bookingUid: "booking-uid-123",
+ userId: 123,
+ userEmail: "user@example.com",
+ userTimeZone: "UTC",
+ organizationId: 200,
+ });
+
+ expect(result.auditLogs[0]).toMatchObject({
+ id: "fallback-test-log",
+ bookingUid: "booking-uid-123",
+ action: "CREATED",
+ type: "RECORD_CREATED",
+ timestamp: timestamp.toISOString(),
+ createdAt: createdAt.toISOString(),
+ source: "WEBAPP",
+ hasError: true,
+ actor: expect.objectContaining({
+ type: "USER",
+ displayName: "Test Name",
+ }),
+ });
+ });
+
+ it("should use 'Unknown' as displayName when actor name is null in fallback", async () => {
+ createMockAuditLog("booking-uid-123", {
+ id: "null-name-log",
+ actorType: "USER",
+ actorUserUuid: null,
+ actorName: null,
+ });
+
+ const result = await service.getAuditLogsForBooking({
+ bookingUid: "booking-uid-123",
+ userId: 123,
+ userEmail: "user@example.com",
+ userTimeZone: "UTC",
+ organizationId: 200,
+ });
+
+ expect(result.auditLogs[0].hasError).toBe(true);
+ expect(result.auditLogs[0].actor.displayName).toBe("Unknown");
+ });
+ });
});
});