f1011ddd08
* feat(booking-audit): extract core audit system changes from PR 25125 This PR extracts core audit infrastructure changes without integration changes: 1. New action services introduced: - SeatBookedAuditActionService - SeatRescheduledAuditActionService 2. Simplification of ActionService interface: - Streamlined IAuditActionService interface - Reduced TypeScript burden with cleaner type definitions 3. ActionSource support: - Added BookingAuditSource enum (API_V1, API_V2, WEBAPP, WEBHOOK, UNKNOWN) - Added source and operationId fields to BookingAudit model 4. New AuditAction types: - SEAT_BOOKED - SEAT_RESCHEDULED - APP actor type 5. New BookingAuditAccessService: - Permission-based access control for audit logs - Added readTeamAuditLogs and readOrgAuditLogs permissions 6. Fixes in the logs viewer flow: - Enhanced BookingAuditViewerService with improved filtering - Local AttendeeRepository for actor enrichment Changes are contained within packages/features/booking-audit with minimal outside changes (permission registry only). Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * refactor(booking-audit): streamline action handling and enhance localization - Replaced action icon retrieval with a mapping object for improved clarity and performance. - Introduced constants for actor role labels to simplify role retrieval. - Added new localization strings for audit log permission errors and organization requirements. - Updated various service and repository interfaces to enhance type safety and clarity. - Removed deprecated architecture documentation and adjusted related imports for consistency. These changes aim to improve code maintainability and user experience in the booking audit system. * fix(booking-audit): enhance actor role localization and operation ID tracking - Updated actor role labels in the booking logs view to use lowercase for consistency. - Improved localization by wrapping actor role display in a translation function. - Added operationId field to audit logs for better correlation of actions across multiple bookings. - Enhanced BookingAuditViewerService to include operationId in enriched audit logs. - Updated integration tests to verify consistent operationId across related audit logs. These changes aim to improve localization accuracy and facilitate better tracking of user actions in the booking audit system. * feat: integrate credential repository and enhance app actor handling - Added CredentialRepository to manage app credentials, including a method to find credentials by ID. - Updated BookingAudit system to support app actors identified by credential ID, improving actor attribution and audit clarity. - Introduced a new utility function to map app slugs to display names, enhancing the user experience in audit logs. - Modified relevant interfaces and types to accommodate the new credential handling and app actor structure. - Enhanced BookingAuditViewerService to display app names based on credentials, ensuring accurate representation in audit logs. --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
322 lines
13 KiB
TypeScript
322 lines
13 KiB
TypeScript
import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
|
import type { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
|
|
import type { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
|
|
import type { IAttendeeRepository } from "@calcom/features/bookings/repositories/IAttendeeRepository";
|
|
import type { ISimpleLogger } from "@calcom/features/di/shared/services/logger.service";
|
|
import type { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
|
|
import { BookingAuditActionServiceRegistry } from "./BookingAuditActionServiceRegistry";
|
|
import { BookingAuditAccessService } from "./BookingAuditAccessService";
|
|
import type { IBookingAuditRepository, BookingAuditWithActor, BookingAuditAction, BookingAuditType } from "../repository/IBookingAuditRepository";
|
|
import type { AuditActorType } from "../repository/IAuditActorRepository";
|
|
import type { TranslationWithParams } from "../actions/IAuditActionService";
|
|
import type { ActionSource } from "../types/actionSource";
|
|
import { RescheduledAuditActionService } from "../actions/RescheduledAuditActionService";
|
|
import { getAppNameFromSlug } from "../getAppNameFromSlug";
|
|
|
|
interface BookingAuditViewerServiceDeps {
|
|
bookingAuditRepository: IBookingAuditRepository;
|
|
userRepository: UserRepository;
|
|
bookingRepository: BookingRepository;
|
|
membershipRepository: MembershipRepository;
|
|
attendeeRepository: IAttendeeRepository;
|
|
log: ISimpleLogger;
|
|
credentialRepository: CredentialRepository;
|
|
}
|
|
|
|
type EnrichedAuditLog = {
|
|
id: string;
|
|
bookingUid: string;
|
|
type: BookingAuditType;
|
|
action: BookingAuditAction;
|
|
timestamp: string;
|
|
createdAt: string;
|
|
source: ActionSource;
|
|
operationId: string;
|
|
displayJson?: Record<string, unknown> | null;
|
|
actionDisplayTitle: TranslationWithParams;
|
|
displayFields?: Array<{ labelKey: string; valueKey: string }> | null;
|
|
actor: {
|
|
id: string;
|
|
type: AuditActorType;
|
|
userUuid: string | null;
|
|
attendeeId: number | null;
|
|
name: string | null;
|
|
createdAt: Date;
|
|
displayName: string;
|
|
displayEmail: string | null;
|
|
displayAvatar: string | null;
|
|
};
|
|
};
|
|
|
|
/**
|
|
* BookingAuditViewerService - Service for viewing and formatting booking audit logs
|
|
*/
|
|
export class BookingAuditViewerService {
|
|
private readonly actionServiceRegistry: BookingAuditActionServiceRegistry;
|
|
private readonly bookingAuditRepository: IBookingAuditRepository;
|
|
private readonly userRepository: UserRepository;
|
|
private readonly bookingRepository: BookingRepository;
|
|
private readonly membershipRepository: MembershipRepository;
|
|
private readonly attendeeRepository: IAttendeeRepository;
|
|
private readonly credentialRepository: CredentialRepository;
|
|
private readonly rescheduledAuditActionService: RescheduledAuditActionService;
|
|
private readonly accessService: BookingAuditAccessService;
|
|
private readonly log: BookingAuditViewerServiceDeps["log"];
|
|
|
|
constructor(private readonly deps: BookingAuditViewerServiceDeps) {
|
|
this.bookingAuditRepository = deps.bookingAuditRepository;
|
|
this.userRepository = deps.userRepository;
|
|
this.bookingRepository = deps.bookingRepository;
|
|
this.membershipRepository = deps.membershipRepository;
|
|
this.attendeeRepository = deps.attendeeRepository;
|
|
this.credentialRepository = deps.credentialRepository;
|
|
this.log = deps.log;
|
|
this.rescheduledAuditActionService = new RescheduledAuditActionService();
|
|
this.accessService = new BookingAuditAccessService({
|
|
bookingRepository: this.bookingRepository,
|
|
membershipRepository: this.membershipRepository,
|
|
});
|
|
this.actionServiceRegistry = new BookingAuditActionServiceRegistry({ userRepository: this.userRepository });
|
|
}
|
|
|
|
/**
|
|
* Get audit logs for a booking with full enrichment and formatting
|
|
* Handles permission checks, fetches logs, enriches actors, and formats display
|
|
*
|
|
* For bookings created from a reschedule (has fromReschedule field), this also
|
|
* fetches the last RESCHEDULED log from the previous booking and includes it
|
|
* as the first log entry with "rescheduled from" context.
|
|
*/
|
|
async getAuditLogsForBooking(params: {
|
|
bookingUid: string;
|
|
userId: number;
|
|
userEmail: string;
|
|
userTimeZone: string;
|
|
organizationId: number | null;
|
|
}): Promise<{ bookingUid: string; auditLogs: EnrichedAuditLog[] }> {
|
|
const { bookingUid, userId, userTimeZone, organizationId } = params;
|
|
await this.accessService.assertPermissions({
|
|
bookingUid,
|
|
userId,
|
|
organizationId
|
|
});
|
|
|
|
const auditLogs = await this.bookingAuditRepository.findAllForBooking(bookingUid);
|
|
|
|
const enrichedAuditLogs = await Promise.all(
|
|
auditLogs.map((log) => this.enrichAuditLog(log, userTimeZone))
|
|
);
|
|
|
|
const fromRescheduleUid = await this.bookingRepository.getFromRescheduleUid(bookingUid);
|
|
|
|
// Check if this booking was created from a reschedule
|
|
if (fromRescheduleUid) {
|
|
const rescheduledFromLog = await this.buildRescheduledFromLog({
|
|
fromRescheduleUid,
|
|
currentBookingUid: bookingUid,
|
|
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);
|
|
}
|
|
}
|
|
|
|
return {
|
|
bookingUid: params.bookingUid,
|
|
auditLogs: enrichedAuditLogs,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Enriches a single audit log with actor information and formatted display data
|
|
*/
|
|
private async enrichAuditLog(log: BookingAuditWithActor, userTimeZone: string): Promise<EnrichedAuditLog> {
|
|
const enrichedActor = await this.enrichActorInformation(log.actor);
|
|
|
|
const actionService = this.actionServiceRegistry.getActionService(log.action);
|
|
const parsedData = actionService.parseStored(log.data);
|
|
|
|
const actionDisplayTitle = await actionService.getDisplayTitle({ storedData: parsedData, userTimeZone });
|
|
|
|
const displayJson = actionService.getDisplayJson
|
|
? actionService.getDisplayJson({ storedData: parsedData, userTimeZone })
|
|
: null;
|
|
|
|
const displayFields = actionService.getDisplayFields
|
|
? actionService.getDisplayFields(parsedData)
|
|
: null;
|
|
|
|
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,
|
|
actionDisplayTitle,
|
|
displayFields,
|
|
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: enrichedActor.displayName,
|
|
displayEmail: enrichedActor.displayEmail,
|
|
displayAvatar: enrichedActor.displayAvatar,
|
|
},
|
|
};
|
|
}
|
|
/**
|
|
* Builds a "rescheduled from" log entry for bookings created from a reschedule.
|
|
* Fetches the RESCHEDULED log from the previous booking and transforms it
|
|
* to show "rescheduled from" context for the current booking.
|
|
*/
|
|
private async buildRescheduledFromLog({
|
|
fromRescheduleUid,
|
|
currentBookingUid,
|
|
userTimeZone,
|
|
}: {
|
|
fromRescheduleUid: string;
|
|
currentBookingUid: string;
|
|
userTimeZone: string;
|
|
}): Promise<EnrichedAuditLog | null> {
|
|
const rescheduledLogs = await this.bookingAuditRepository.findRescheduledLogsOfBooking(
|
|
fromRescheduleUid
|
|
);
|
|
|
|
// Find the specific log that created this booking by matching rescheduledToUid
|
|
const rescheduledLog = this.rescheduledAuditActionService.getMatchingLog({
|
|
rescheduledLogs,
|
|
rescheduledToBookingUid: currentBookingUid,
|
|
});
|
|
|
|
if (!rescheduledLog) {
|
|
this.log.error(`No rescheduled log found for booking ${fromRescheduleUid} -> ${currentBookingUid}`);
|
|
// Instead of crashing, we ignore because it is important to be able to access other logs as well.
|
|
return null;
|
|
}
|
|
|
|
const enrichedLog = await this.enrichAuditLog(rescheduledLog, userTimeZone);
|
|
const parsedData = this.rescheduledAuditActionService.parseStored(rescheduledLog.data);
|
|
|
|
// Transform the display JSON to show "rescheduled from" instead of "rescheduled to"
|
|
// by replacing rescheduledToUid with rescheduledFromUid
|
|
const transformedDisplayJson = enrichedLog.displayJson
|
|
? {
|
|
...enrichedLog.displayJson,
|
|
rescheduledFromUid: fromRescheduleUid,
|
|
}
|
|
: undefined;
|
|
|
|
return {
|
|
...enrichedLog,
|
|
// Override bookingUid to associate with the current booking being viewed
|
|
bookingUid: currentBookingUid,
|
|
displayJson: transformedDisplayJson,
|
|
// Use a different translation key to show "Rescheduled from" instead of "Rescheduled"
|
|
actionDisplayTitle: this.rescheduledAuditActionService.getDisplayTitleForRescheduledFromLog({
|
|
fromRescheduleUid,
|
|
userTimeZone,
|
|
storedData: parsedData,
|
|
}),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Enrich actor information with user details if userUuid exists
|
|
*/
|
|
private async enrichActorInformation(actor: BookingAuditWithActor["actor"]): Promise<{
|
|
displayName: string;
|
|
displayEmail: string | null;
|
|
displayAvatar: string | null;
|
|
}> {
|
|
switch (actor.type) {
|
|
case "SYSTEM":
|
|
return {
|
|
displayName: "Cal.com",
|
|
displayEmail: null,
|
|
displayAvatar: null,
|
|
};
|
|
|
|
case "GUEST":
|
|
return {
|
|
displayName: actor.name || "Guest",
|
|
displayEmail: null,
|
|
displayAvatar: null,
|
|
};
|
|
|
|
case "APP": {
|
|
if (actor.credentialId) {
|
|
const credential = await this.deps.credentialRepository.findByCredentialId(actor.credentialId);
|
|
if (credential) {
|
|
return {
|
|
displayName: getAppNameFromSlug({ appSlug: credential.appId }),
|
|
displayEmail: null,
|
|
displayAvatar: null,
|
|
};
|
|
} else {
|
|
return {
|
|
// Expect that on Credential deletion name would have been set
|
|
displayName: actor.name ?? "Deleted App",
|
|
displayEmail: null,
|
|
displayAvatar: null,
|
|
};
|
|
}
|
|
}
|
|
// We allow creating App actor without credentialId
|
|
return {
|
|
displayName: actor.name ?? "Unknown App",
|
|
// We don't want to show email for App actor as that is an internal email with the purpose of giving uniqueness to each app actor
|
|
displayEmail: null,
|
|
displayAvatar: null,
|
|
};
|
|
}
|
|
|
|
case "ATTENDEE": {
|
|
if (!actor.attendeeId) {
|
|
throw new Error("Attendee ID is required for ATTENDEE actor");
|
|
}
|
|
const attendee = await this.attendeeRepository.findById(actor.attendeeId);
|
|
if (attendee) {
|
|
return {
|
|
displayName: attendee.name || attendee.email,
|
|
displayEmail: attendee.email,
|
|
displayAvatar: null,
|
|
};
|
|
}
|
|
return {
|
|
displayName: "Deleted Attendee",
|
|
displayEmail: null,
|
|
displayAvatar: null,
|
|
};
|
|
}
|
|
|
|
case "USER": {
|
|
if (!actor.userUuid) {
|
|
throw new Error("User UUID is required for USER actor");
|
|
}
|
|
const actorUser = await this.userRepository.findByUuid({ uuid: actor.userUuid });
|
|
if (actorUser) {
|
|
return {
|
|
displayName: actorUser.name || actorUser.email,
|
|
displayEmail: actorUser.email,
|
|
displayAvatar: actorUser.avatarUrl || null,
|
|
};
|
|
}
|
|
return {
|
|
displayName: "Deleted User",
|
|
displayEmail: null,
|
|
displayAvatar: null,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
}
|