diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 17ebb8f3ee..b525e32b28 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -4393,7 +4393,8 @@ "assignment_type_manual": "Manual", "assignment_type_round_robin": "Round robin", "actor_impersonated_by": "Impersonator", - "source": "Source" + "source": "Source", + "error_processing": "Unable to load details - {{actionType}}" }, "error_loading_booking_logs": "Error loading booking logs", "no_audit_logs_found": "No audit logs found", diff --git a/packages/features/booking-audit/client/components/BookingHistory.tsx b/packages/features/booking-audit/client/components/BookingHistory.tsx index bc87c0c695..48d0c1d8cc 100644 --- a/packages/features/booking-audit/client/components/BookingHistory.tsx +++ b/packages/features/booking-audit/client/components/BookingHistory.tsx @@ -48,6 +48,7 @@ type AuditLog = { displayEmail: string | null; displayAvatar: string | null; } | null; + hasError?: boolean; }; interface BookingLogsFiltersProps { @@ -225,7 +226,7 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
- +
{!isLast &&
} @@ -235,7 +236,7 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
-

+

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"); + }); + }); }); });