feat: Add infrastructure for no-show audit integration (#27187)
* feat: Add infrastructure for no-show audit integration - Add Prisma migrations for SYSTEM source and NO_SHOW_UPDATED audit action - Add NoShowUpdatedAuditActionService with array-based attendeesNoShow schema - Update BookingAuditActionServiceRegistry to include NO_SHOW_UPDATED - Update BookingAuditTaskConsumer and BookingAuditViewerService - Add AttendeeRepository methods for no-show queries - Update IAuditActionService interface with values array support - Update locales with no-show audit translation keys Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * fix: Add NO_SHOW_UPDATED to BookingAuditAction and SYSTEM to ActionSource types Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * fix: Remove HOST_NO_SHOW_UPDATED and ATTENDEE_NO_SHOW_UPDATED from BookingAuditAction type to match Prisma schema Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * fix: Update BookingAuditActionSchema to use NO_SHOW_UPDATED instead of HOST_NO_SHOW_UPDATED and ATTENDEE_NO_SHOW_UPDATED Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * refactor: Remove deprecated no-show audit services and unify to NoShowUpdatedAuditActionService - Delete HostNoShowUpdatedAuditActionService and AttendeeNoShowUpdatedAuditActionService - Update BookingAuditProducerService.interface.ts to use queueNoShowUpdatedAudit - Update BookingAuditTaskerProducerService.ts to use queueNoShowUpdatedAudit - Update BookingEventHandlerService.ts to use onNoShowUpdated - Add integration tests for NoShowUpdatedAuditActionService Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * fix: Add data migration step for deprecated no-show enum values Addresses Cubic AI review feedback (confidence 9/10): The migration now includes an UPDATE statement to convert existing records using the deprecated 'host_no_show_updated' or 'attendee_no_show_updated' enum values to the new unified 'no_show_updated' value before the type cast. This prevents migration failures if any existing data uses the old values. Co-Authored-By: unknown <> * fix: Use CASE expression in USING clause for enum migration Fixes PostgreSQL error 'unsafe use of new value of enum type' by avoiding the ADD VALUE statement and instead using a CASE expression in the ALTER TABLE USING clause to convert deprecated enum values (host_no_show_updated, attendee_no_show_updated) to the new unified value (no_show_updated) during the type conversion. Co-Authored-By: unknown <> * fix: Replace hardcoded color with semantic text-success class Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * fix: Remove color class completely from display fields Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
26 files changed
+1195
-697
No files matched your search
@@ -36,7 +36,7 @@ type AuditLog = {
|
||||
source: string;
|
||||
displayJson?: Record<string, unknown> | null;
|
||||
actionDisplayTitle: TranslationWithParams;
|
||||
displayFields?: Array<{ labelKey: string; valueKey: string }> | null;
|
||||
displayFields?: Array<{ labelKey: string; valueKey?: string; value?: string; values?: string[] }> | null;
|
||||
actor: {
|
||||
type: AuditActorType;
|
||||
displayName: string | null;
|
||||
@@ -74,8 +74,7 @@ const ACTION_ICON_MAP: Record<string, IconName> = {
|
||||
ATTENDEE_ADDED: "user-check",
|
||||
ATTENDEE_REMOVED: "user-check",
|
||||
LOCATION_CHANGED: "map-pin",
|
||||
HOST_NO_SHOW_UPDATED: "ban",
|
||||
ATTENDEE_NO_SHOW_UPDATED: "ban",
|
||||
NO_SHOW_UPDATED: "ban",
|
||||
} as const;
|
||||
|
||||
const ACTOR_ROLE_LABEL_MAP: Record<AuditActorType, string | null> = {
|
||||
@@ -182,6 +181,32 @@ function JsonViewer({ data }: JsonViewerProps) {
|
||||
);
|
||||
}
|
||||
|
||||
interface DisplayFieldValueProps {
|
||||
field: {
|
||||
valueKey?: string;
|
||||
value?: string;
|
||||
values?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
function DisplayFieldValue({ field }: DisplayFieldValueProps) {
|
||||
const { t } = useLocale();
|
||||
|
||||
if (field.values) {
|
||||
return (
|
||||
<span className="flex flex-col">
|
||||
{field.values.map((v, i) => (
|
||||
<span className="p-0.5" key={i}>
|
||||
{v}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{field.value ?? (field.valueKey ? t(field.valueKey) : "")}</>;
|
||||
}
|
||||
|
||||
function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
|
||||
const { t } = useLocale();
|
||||
const [expandedLogIds, setExpandedLogIds] = useState<Set<string>>(new Set());
|
||||
@@ -279,10 +304,10 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-start gap-2 py-2 border-b px-3 border-subtle">
|
||||
<span className="font-medium text-emphasis w-[140px]">
|
||||
{t(field.labelKey)}
|
||||
<span className="font-medium text-emphasis w-[140px]">{t(field.labelKey)}</span>
|
||||
<span className="font-medium">
|
||||
<DisplayFieldValue field={field} />
|
||||
</span>
|
||||
<span className="text-[#096638] font-medium">{t(field.valueKey)}</span>
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
@@ -342,15 +367,23 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
|
||||
function useBookingLogsFilters(auditLogs: AuditLog[], searchTerm: string, actorFilter: string | null) {
|
||||
const filteredLogs = auditLogs.filter((log) => {
|
||||
const doesMatchDisplayFields = (): boolean => {
|
||||
return log.displayFields?.some((field) => {
|
||||
return field.valueKey?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
field.labelKey?.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
}) ?? false;
|
||||
return (
|
||||
log.displayFields?.some((field) => {
|
||||
return (
|
||||
field.valueKey?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
field.labelKey?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
}) ?? false
|
||||
);
|
||||
};
|
||||
|
||||
const doesMatchActionDisplayTitle = (): boolean => {
|
||||
return log.actionDisplayTitle.key?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
Object.values(log.actionDisplayTitle.params ?? {}).some((param) => param?.toString().toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
return (
|
||||
log.actionDisplayTitle.key?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
Object.values(log.actionDisplayTitle.params ?? {}).some((param) =>
|
||||
param?.toString().toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const matchesSearch =
|
||||
|
||||
@@ -4455,13 +4455,14 @@
|
||||
"reschedule_requested": "Reschedule requested",
|
||||
"attendee_added": "Attendee added",
|
||||
"host_no_show_updated": "Host no-show updated",
|
||||
"attendee_no_show_updated": "Attendee no-show updated",
|
||||
"no_show_updated": "No-show updated",
|
||||
"rejected": "Rejected",
|
||||
"attendee_removed": "Attendee removed",
|
||||
"reassignment": "Reassignment",
|
||||
"booking_reassigned_to_host": "Booking reassigned to {{host}}",
|
||||
"location_changed": "Location changed",
|
||||
"location_changed_from_to": "Location changed from {{fromLocation}} to {{toLocation}}",
|
||||
"attendee_no_show_updated": "Attendee no-show updated",
|
||||
"seat_booked": "Seat booked",
|
||||
"seat_rescheduled": "Seat rescheduled {{oldDate}} -> <0>{{newDate}}</0>",
|
||||
"assignment_type": "Assignment",
|
||||
|
||||
@@ -51,6 +51,7 @@ The booking audit system uses two complementary fields:
|
||||
- **API_V1**: API v1 endpoint
|
||||
- **API_V2**: API v2 endpoint
|
||||
- **WEBHOOK**: External webhook (e.g., Stripe)
|
||||
- **SYSTEM**: Background job (e.g., Tasker's task, trigger.dev job for automatic no-show detection)
|
||||
- **UNKNOWN**: Source cannot be determined
|
||||
|
||||
### Actor: The Entity
|
||||
@@ -75,8 +76,7 @@ The system tracks various booking actions including:
|
||||
- **ATTENDEE_REMOVED**: Attendee removed
|
||||
- **REASSIGNMENT**: Booking reassigned to different host
|
||||
- **LOCATION_CHANGED**: Meeting location updated
|
||||
- **HOST_NO_SHOW_UPDATED**: Host no-show status changed
|
||||
- **ATTENDEE_NO_SHOW_UPDATED**: Attendee no-show status changed
|
||||
- **NO_SHOW_UPDATED**: Host or attendee no-show status changed
|
||||
- **SEAT_BOOKED**: Seat reserved in group booking
|
||||
- **SEAT_RESCHEDULED**: Seat rescheduled in group booking
|
||||
|
||||
@@ -204,7 +204,7 @@ The audit system works with third-party queue providers without exposing PII:
|
||||
- `operationId`: Required string for correlating related audit logs
|
||||
- `data`: Action-specific data
|
||||
- `timestamp`: Number (milliseconds since epoch)
|
||||
- `source`: Action source (API_V1, API_V2, WEBAPP, WEBHOOK, UNKNOWN)
|
||||
- `source`: Action source (API_V1, API_V2, WEBAPP, WEBHOOK, SYSTEM, UNKNOWN)
|
||||
|
||||
**BookingAuditTaskConsumer** processes audit records:
|
||||
- Validates queue payload structure
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BookingAuditTaskConsumer } from "@calcom/features/booking-audit/lib/ser
|
||||
import { BOOKING_AUDIT_DI_TOKENS } from "@calcom/features/booking-audit/di/tokens";
|
||||
import { moduleLoader as bookingAuditRepositoryModuleLoader } from "@calcom/features/booking-audit/di/BookingAuditRepository.module";
|
||||
import { moduleLoader as auditActorRepositoryModuleLoader } from "@calcom/features/booking-audit/di/AuditActorRepository.module";
|
||||
import { moduleLoader as attendeeRepositoryModuleLoader } from "@calcom/features/bookings/di/Attendee.module";
|
||||
import { moduleLoader as featuresRepositoryModuleLoader } from "@calcom/features/di/modules/FeaturesRepository";
|
||||
import { moduleLoader as userRepositoryModuleLoader } from "@calcom/features/di/modules/User";
|
||||
|
||||
@@ -20,6 +21,7 @@ const loadModule = bindModuleToClassOnToken({
|
||||
bookingAuditRepository: bookingAuditRepositoryModuleLoader,
|
||||
auditActorRepository: auditActorRepositoryModuleLoader,
|
||||
featuresRepository: featuresRepositoryModuleLoader,
|
||||
attendeeRepository: attendeeRepositoryModuleLoader,
|
||||
userRepository: userRepositoryModuleLoader,
|
||||
},
|
||||
});
|
||||
|
||||
-81
@@ -1,81 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { BooleanChangeSchema } from "../common/changeSchemas";
|
||||
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
|
||||
import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, GetDisplayJsonParams } from "./IAuditActionService";
|
||||
|
||||
/**
|
||||
* Attendee No-Show Updated Audit Action Service
|
||||
* Handles ATTENDEE_NO_SHOW_UPDATED action with per-action versioning
|
||||
*/
|
||||
|
||||
// Module-level because it is passed to IAuditActionService type outside the class scope
|
||||
const fieldsSchemaV1 = z.object({
|
||||
noShowAttendee: BooleanChangeSchema,
|
||||
});
|
||||
|
||||
export class AttendeeNoShowUpdatedAuditActionService implements IAuditActionService {
|
||||
readonly VERSION = 1;
|
||||
public static readonly TYPE = "ATTENDEE_NO_SHOW_UPDATED" as const;
|
||||
private static dataSchemaV1 = z.object({
|
||||
version: z.literal(1),
|
||||
fields: fieldsSchemaV1,
|
||||
});
|
||||
private static fieldsSchemaV1 = fieldsSchemaV1;
|
||||
public static readonly latestFieldsSchema = fieldsSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedDataSchema = AttendeeNoShowUpdatedAuditActionService.dataSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedFieldsSchema = AttendeeNoShowUpdatedAuditActionService.fieldsSchemaV1;
|
||||
private helper: AuditActionServiceHelper<
|
||||
typeof AttendeeNoShowUpdatedAuditActionService.latestFieldsSchema,
|
||||
typeof AttendeeNoShowUpdatedAuditActionService.storedDataSchema
|
||||
>;
|
||||
|
||||
constructor() {
|
||||
this.helper = new AuditActionServiceHelper({
|
||||
latestVersion: this.VERSION,
|
||||
latestFieldsSchema: AttendeeNoShowUpdatedAuditActionService.latestFieldsSchema,
|
||||
storedDataSchema: AttendeeNoShowUpdatedAuditActionService.storedDataSchema,
|
||||
});
|
||||
}
|
||||
|
||||
getVersionedData(fields: unknown) {
|
||||
return this.helper.getVersionedData(fields);
|
||||
}
|
||||
|
||||
parseStored(data: unknown) {
|
||||
return this.helper.parseStored(data);
|
||||
}
|
||||
|
||||
getVersion(data: unknown): number {
|
||||
return this.helper.getVersion(data);
|
||||
}
|
||||
|
||||
migrateToLatest(data: unknown) {
|
||||
// V1-only: validate and return as-is (no migration needed)
|
||||
const validated = fieldsSchemaV1.parse(data);
|
||||
return { isMigrated: false, latestData: validated };
|
||||
}
|
||||
|
||||
async getDisplayTitle(_: GetDisplayTitleParams): Promise<TranslationWithParams> {
|
||||
return { key: "booking_audit_action.attendee_no_show_updated" };
|
||||
}
|
||||
|
||||
getDisplayJson({
|
||||
storedData,
|
||||
}: GetDisplayJsonParams): AttendeeNoShowUpdatedAuditDisplayData {
|
||||
const { fields } = this.parseStored({ version: storedData.version, fields: storedData.fields });
|
||||
return {
|
||||
noShowAttendee: fields.noShowAttendee.new,
|
||||
previousNoShowAttendee: fields.noShowAttendee.old ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type AttendeeNoShowUpdatedAuditData = z.infer<typeof fieldsSchemaV1>;
|
||||
|
||||
export type AttendeeNoShowUpdatedAuditDisplayData = {
|
||||
noShowAttendee: boolean;
|
||||
previousNoShowAttendee: boolean | null;
|
||||
};
|
||||
@@ -1,81 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { BooleanChangeSchema } from "../common/changeSchemas";
|
||||
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
|
||||
import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, GetDisplayJsonParams } from "./IAuditActionService";
|
||||
|
||||
/**
|
||||
* Host No-Show Updated Audit Action Service
|
||||
* Handles HOST_NO_SHOW_UPDATED action with per-action versioning
|
||||
*/
|
||||
|
||||
// Module-level because it is passed to IAuditActionService type outside the class scope
|
||||
const fieldsSchemaV1 = z.object({
|
||||
noShowHost: BooleanChangeSchema,
|
||||
});
|
||||
|
||||
export class HostNoShowUpdatedAuditActionService implements IAuditActionService {
|
||||
readonly VERSION = 1;
|
||||
public static readonly TYPE = "HOST_NO_SHOW_UPDATED" as const;
|
||||
private static dataSchemaV1 = z.object({
|
||||
version: z.literal(1),
|
||||
fields: fieldsSchemaV1,
|
||||
});
|
||||
private static fieldsSchemaV1 = fieldsSchemaV1;
|
||||
public static readonly latestFieldsSchema = fieldsSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedDataSchema = HostNoShowUpdatedAuditActionService.dataSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedFieldsSchema = HostNoShowUpdatedAuditActionService.fieldsSchemaV1;
|
||||
private helper: AuditActionServiceHelper<
|
||||
typeof HostNoShowUpdatedAuditActionService.latestFieldsSchema,
|
||||
typeof HostNoShowUpdatedAuditActionService.storedDataSchema
|
||||
>;
|
||||
|
||||
constructor() {
|
||||
this.helper = new AuditActionServiceHelper({
|
||||
latestVersion: this.VERSION,
|
||||
latestFieldsSchema: HostNoShowUpdatedAuditActionService.latestFieldsSchema,
|
||||
storedDataSchema: HostNoShowUpdatedAuditActionService.storedDataSchema,
|
||||
});
|
||||
}
|
||||
|
||||
getVersionedData(fields: unknown) {
|
||||
return this.helper.getVersionedData(fields);
|
||||
}
|
||||
|
||||
parseStored(data: unknown) {
|
||||
return this.helper.parseStored(data);
|
||||
}
|
||||
|
||||
getVersion(data: unknown): number {
|
||||
return this.helper.getVersion(data);
|
||||
}
|
||||
|
||||
migrateToLatest(data: unknown) {
|
||||
// V1-only: validate and return as-is (no migration needed)
|
||||
const validated = fieldsSchemaV1.parse(data);
|
||||
return { isMigrated: false, latestData: validated };
|
||||
}
|
||||
|
||||
async getDisplayTitle(_: GetDisplayTitleParams): Promise<TranslationWithParams> {
|
||||
return { key: "booking_audit_action.host_no_show_updated" };
|
||||
}
|
||||
|
||||
getDisplayJson({
|
||||
storedData,
|
||||
}: GetDisplayJsonParams): HostNoShowUpdatedAuditDisplayData {
|
||||
const { fields } = this.parseStored({ version: storedData.version, fields: storedData.fields });
|
||||
return {
|
||||
noShowHost: fields.noShowHost.new,
|
||||
previousNoShowHost: fields.noShowHost.old ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type HostNoShowUpdatedAuditData = z.infer<typeof fieldsSchemaV1>;
|
||||
|
||||
export type HostNoShowUpdatedAuditDisplayData = {
|
||||
noShowHost: boolean;
|
||||
previousNoShowHost: boolean | null;
|
||||
};
|
||||
@@ -3,8 +3,8 @@
|
||||
* Used with react-i18next Trans component for proper i18n support (RTL, word order, etc.)
|
||||
*/
|
||||
export type TranslationComponent = {
|
||||
type: "link";
|
||||
href: string;
|
||||
type: "link";
|
||||
href: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -13,117 +13,126 @@ export type TranslationComponent = {
|
||||
* Components are used for clickable links within translations (e.g., "Rescheduled to <1>New Booking</1>")
|
||||
*/
|
||||
export type TranslationWithParams = {
|
||||
key: string;
|
||||
params?: Record<string, string | number>;
|
||||
components?: TranslationComponent[];
|
||||
key: string;
|
||||
params?: Record<string, string | number>;
|
||||
components?: TranslationComponent[];
|
||||
};
|
||||
|
||||
/**
|
||||
* This is agnostic of the action and is common for all actions
|
||||
*/
|
||||
export type BaseStoredAuditData = {
|
||||
version: number;
|
||||
fields: Record<string, unknown>;
|
||||
version: number;
|
||||
fields: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type GetDisplayJsonParams = {
|
||||
storedData: BaseStoredAuditData;
|
||||
userTimeZone: string;
|
||||
storedData: BaseStoredAuditData;
|
||||
userTimeZone: string;
|
||||
};
|
||||
|
||||
export type GetDisplayTitleParams = {
|
||||
storedData: BaseStoredAuditData;
|
||||
userTimeZone: string;
|
||||
storedData: BaseStoredAuditData;
|
||||
userTimeZone: string;
|
||||
};
|
||||
|
||||
export type GetDisplayFieldsParams = {
|
||||
storedData: BaseStoredAuditData;
|
||||
};
|
||||
|
||||
/**
|
||||
* Interface for Audit Action Services
|
||||
*
|
||||
*
|
||||
* Defines the contract that all audit action services must implement.
|
||||
* Uses composition with AuditActionServiceHelper to provide common functionality
|
||||
* while maintaining runtime type safety through Zod schema validation.
|
||||
*
|
||||
*
|
||||
* All methods use common base types at the interface level, while implementations
|
||||
* validate to their specific schemas internally.
|
||||
*/
|
||||
export interface IAuditActionService {
|
||||
/**
|
||||
* Current version number for this action type
|
||||
*/
|
||||
readonly VERSION: number;
|
||||
/**
|
||||
* Current version number for this action type
|
||||
*/
|
||||
readonly VERSION: number;
|
||||
|
||||
/**
|
||||
* Parse given fields against latest schema and wrap with version
|
||||
* @param fields - Raw input fields (just the audit fields)
|
||||
* @returns Parsed data with version wrapper { version, fields }
|
||||
*/
|
||||
getVersionedData(fields: unknown): BaseStoredAuditData;
|
||||
/**
|
||||
* Parse given fields against latest schema and wrap with version
|
||||
* @param fields - Raw input fields (just the audit fields)
|
||||
* @returns Parsed data with version wrapper { version, fields }
|
||||
*/
|
||||
getVersionedData(fields: unknown): BaseStoredAuditData;
|
||||
|
||||
/**
|
||||
* Parse stored audit record (includes version wrapper)
|
||||
* Accepts data from ANY supported version (not just latest) for backward compatibility
|
||||
* @param data - Stored data from database (can be any version)
|
||||
* @returns Parsed stored data { version, fields } - version may differ from current VERSION
|
||||
*/
|
||||
parseStored(data: unknown): BaseStoredAuditData;
|
||||
/**
|
||||
* Parse stored audit record (includes version wrapper)
|
||||
* Accepts data from ANY supported version (not just latest) for backward compatibility
|
||||
* @param data - Stored data from database (can be any version)
|
||||
* @returns Parsed stored data { version, fields } - version may differ from current VERSION
|
||||
*/
|
||||
parseStored(data: unknown): BaseStoredAuditData;
|
||||
|
||||
/**
|
||||
* Extract version number from stored data
|
||||
* @param data - Stored data from database
|
||||
* @returns Version number
|
||||
*/
|
||||
getVersion(data: unknown): number;
|
||||
/**
|
||||
* Extract version number from stored data
|
||||
* @param data - Stored data from database
|
||||
* @returns Version number
|
||||
*/
|
||||
getVersion(data: unknown): number;
|
||||
|
||||
/**
|
||||
* Get flattened JSON data for display (fields only, no version wrapper)
|
||||
* Optional - implement only if custom display formatting is needed
|
||||
* @param params - Object containing storedData and userTimeZone
|
||||
* @param params.storedData - Parsed stored data { version, fields }
|
||||
* @param params.userTimeZone - User's timezone for datetime formatting (required)
|
||||
* @returns The fields object without version wrapper and we decide what fields to show to the client
|
||||
*/
|
||||
getDisplayJson?(params: GetDisplayJsonParams): Record<string, unknown>;
|
||||
/**
|
||||
* Get flattened JSON data for display (fields only, no version wrapper)
|
||||
* Optional - implement only if custom display formatting is needed
|
||||
* @param params - Object containing storedData and userTimeZone
|
||||
* @param params.storedData - Parsed stored data { version, fields }
|
||||
* @param params.userTimeZone - User's timezone for datetime formatting (required)
|
||||
* @returns The fields object without version wrapper and we decide what fields to show to the client
|
||||
*/
|
||||
getDisplayJson?(params: GetDisplayJsonParams): Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Get the display title for the audit action
|
||||
* Returns a translation key with optional interpolation params for dynamic titles
|
||||
* (e.g., "Booking reassigned to John Doe" instead of just "Reassignment")
|
||||
* @param params - Object containing storedData and userTimeZone
|
||||
* @param params.storedData - Parsed stored data { version, fields }
|
||||
* @param params.userTimeZone - User's timezone for date formatting (required)
|
||||
* @returns Translation key with optional interpolation params
|
||||
*/
|
||||
getDisplayTitle(params: GetDisplayTitleParams): Promise<TranslationWithParams>;
|
||||
/**
|
||||
* Get the display title for the audit action
|
||||
* Returns a translation key with optional interpolation params for dynamic titles
|
||||
* (e.g., "Booking reassigned to John Doe" instead of just "Reassignment")
|
||||
* @param params - Object containing storedData and userTimeZone
|
||||
* @param params.storedData - Parsed stored data { version, fields }
|
||||
* @param params.userTimeZone - User's timezone for date formatting (required)
|
||||
* @returns Translation key with optional interpolation params
|
||||
*/
|
||||
getDisplayTitle(params: GetDisplayTitleParams): Promise<TranslationWithParams>;
|
||||
|
||||
/**
|
||||
* Returns additional display fields with translation keys for frontend rendering
|
||||
* Optional - implement only if custom display fields are needed
|
||||
* @param storedData - Parsed stored data { version, fields }
|
||||
* @returns Promise of array of field objects with label and value translation keys
|
||||
*/
|
||||
getDisplayFields?(storedData: BaseStoredAuditData): Promise<Array<{
|
||||
labelKey: string; // Translation key for field label
|
||||
valueKey: string; // Translation key for field value
|
||||
}>>;
|
||||
/**
|
||||
* Returns additional display fields with translation keys for frontend rendering
|
||||
* Optional - implement only if custom display fields are needed
|
||||
* @param params - Object containing storedData
|
||||
* @param params.storedData - Parsed stored data { version, fields }
|
||||
* @returns Promise of array of field objects with label and value (either translation key or raw value)
|
||||
*/
|
||||
getDisplayFields?(params: GetDisplayFieldsParams): Promise<
|
||||
Array<{
|
||||
labelKey: string; // Translation key for field label
|
||||
valueKey?: string; // Translation key for field value (will be translated)
|
||||
value?: string; // Raw value for field value (will NOT be translated)
|
||||
values?: string[]; // Array of raw values (will NOT be translated, rendered as separate lines)
|
||||
}>
|
||||
>;
|
||||
|
||||
/**
|
||||
* Migrate old version data to latest version
|
||||
*
|
||||
* Required method that validates and migrates data to latest schema version.
|
||||
* For V1-only actions, simply validates and returns with isMigrated=false.
|
||||
* For multi-version actions, checks version and transforms if needed.
|
||||
*
|
||||
* @param data - Data from task payload (any supported version)
|
||||
* @returns Migration result with status and latest data
|
||||
*/
|
||||
migrateToLatest(data: unknown): {
|
||||
/**
|
||||
* Migrate old version data to latest version
|
||||
*
|
||||
* Required method that validates and migrates data to latest schema version.
|
||||
* For V1-only actions, simply validates and returns with isMigrated=false.
|
||||
* For multi-version actions, checks version and transforms if needed.
|
||||
*
|
||||
* @param data - Data from task payload (any supported version)
|
||||
* @returns Migration result with status and latest data
|
||||
* True if migration was performed, false if already latest
|
||||
*/
|
||||
migrateToLatest(data: unknown): {
|
||||
/**
|
||||
* True if migration was performed, false if already latest
|
||||
*/
|
||||
isMigrated: boolean;
|
||||
/**
|
||||
* Always set, either migrated or original
|
||||
*/
|
||||
latestData: Record<string, unknown>;
|
||||
};
|
||||
isMigrated: boolean;
|
||||
/**
|
||||
* Always set, either migrated or original
|
||||
*/
|
||||
latestData: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { IAttendeeRepository } from "@calcom/features/bookings/repositories/IAttendeeRepository";
|
||||
import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import type { Ensure } from "@calcom/types/utils";
|
||||
import { z } from "zod";
|
||||
import { BooleanChangeSchema } from "../common/changeSchemas";
|
||||
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
|
||||
import type {
|
||||
GetDisplayFieldsParams,
|
||||
GetDisplayJsonParams,
|
||||
GetDisplayTitleParams,
|
||||
IAuditActionService,
|
||||
TranslationWithParams,
|
||||
} from "./IAuditActionService";
|
||||
|
||||
const AttendeeNoShowSchema = z.object({
|
||||
attendeeEmail: z.string(),
|
||||
noShow: BooleanChangeSchema,
|
||||
});
|
||||
|
||||
const fieldsSchemaV1 = z
|
||||
.object({
|
||||
host: z
|
||||
.object({
|
||||
userUuid: z.string(),
|
||||
noShow: BooleanChangeSchema,
|
||||
})
|
||||
.optional(),
|
||||
attendeesNoShow: z.array(AttendeeNoShowSchema).optional(),
|
||||
})
|
||||
.refine((data) => data.host !== undefined || data.attendeesNoShow !== undefined, {
|
||||
message: "At least one of host or attendeesNoShow must be provided",
|
||||
});
|
||||
|
||||
type Deps = {
|
||||
attendeeRepository: IAttendeeRepository;
|
||||
userRepository: UserRepository;
|
||||
};
|
||||
|
||||
export class NoShowUpdatedAuditActionService implements IAuditActionService {
|
||||
readonly VERSION = 1;
|
||||
public static readonly TYPE = "NO_SHOW_UPDATED" as const;
|
||||
private static dataSchemaV1 = z.object({
|
||||
version: z.literal(1),
|
||||
fields: fieldsSchemaV1,
|
||||
});
|
||||
private static fieldsSchemaV1 = fieldsSchemaV1;
|
||||
public static readonly latestFieldsSchema = fieldsSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedDataSchema = NoShowUpdatedAuditActionService.dataSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedFieldsSchema = NoShowUpdatedAuditActionService.fieldsSchemaV1;
|
||||
private helper: AuditActionServiceHelper<
|
||||
typeof NoShowUpdatedAuditActionService.latestFieldsSchema,
|
||||
typeof NoShowUpdatedAuditActionService.storedDataSchema
|
||||
>;
|
||||
|
||||
constructor(private readonly deps: Deps) {
|
||||
this.helper = new AuditActionServiceHelper({
|
||||
latestVersion: this.VERSION,
|
||||
latestFieldsSchema: NoShowUpdatedAuditActionService.latestFieldsSchema,
|
||||
storedDataSchema: NoShowUpdatedAuditActionService.storedDataSchema,
|
||||
});
|
||||
}
|
||||
|
||||
getVersionedData(fields: unknown) {
|
||||
return this.helper.getVersionedData(fields);
|
||||
}
|
||||
|
||||
parseStored(data: unknown) {
|
||||
return this.helper.parseStored(data);
|
||||
}
|
||||
|
||||
getVersion(data: unknown): number {
|
||||
return this.helper.getVersion(data);
|
||||
}
|
||||
|
||||
migrateToLatest(data: unknown) {
|
||||
// V1-only: validate and return as-is (no migration needed)
|
||||
const validated = fieldsSchemaV1.parse(data);
|
||||
return { isMigrated: false, latestData: validated };
|
||||
}
|
||||
|
||||
private isHostSet(fields: NoShowUpdatedAuditData): fields is Ensure<NoShowUpdatedAuditData, "host"> {
|
||||
return fields.host !== undefined;
|
||||
}
|
||||
|
||||
private isAttendeesNoShowSet(
|
||||
fields: NoShowUpdatedAuditData
|
||||
): fields is Ensure<NoShowUpdatedAuditData, "attendeesNoShow"> {
|
||||
return fields.attendeesNoShow !== undefined;
|
||||
}
|
||||
|
||||
async getDisplayTitle({ storedData }: GetDisplayTitleParams): Promise<TranslationWithParams> {
|
||||
const { fields } = this.parseStored({ version: storedData.version, fields: storedData.fields });
|
||||
const parsedFields = fields as NoShowUpdatedAuditData;
|
||||
if (this.isHostSet(parsedFields) && this.isAttendeesNoShowSet(parsedFields)) {
|
||||
return { key: "booking_audit_action.no_show_updated" };
|
||||
}
|
||||
if (this.isHostSet(parsedFields)) {
|
||||
return { key: "booking_audit_action.host_no_show_updated" };
|
||||
}
|
||||
if (this.isAttendeesNoShowSet(parsedFields)) {
|
||||
return { key: "booking_audit_action.attendee_no_show_updated" };
|
||||
}
|
||||
throw new Error("Audit action data is invalid");
|
||||
}
|
||||
|
||||
async getDisplayFields({ storedData }: GetDisplayFieldsParams): Promise<
|
||||
Array<{
|
||||
labelKey: string;
|
||||
valueKey?: string;
|
||||
value?: string;
|
||||
values?: string[];
|
||||
}>
|
||||
> {
|
||||
const { fields } = this.parseStored(storedData);
|
||||
const parsedFields = fields as NoShowUpdatedAuditData;
|
||||
const displayFields: { labelKey: string; valueKey?: string; value?: string; values?: string[] }[] = [];
|
||||
|
||||
if (this.isAttendeesNoShowSet(parsedFields)) {
|
||||
const attendeesFieldValues = parsedFields.attendeesNoShow.map((attendee) => {
|
||||
const noShowStatus = attendee.noShow.new ? "Yes" : "No";
|
||||
return `${attendee.attendeeEmail}: ${noShowStatus}`;
|
||||
});
|
||||
displayFields.push({ labelKey: "Attendees", values: attendeesFieldValues });
|
||||
}
|
||||
|
||||
if (this.isHostSet(parsedFields)) {
|
||||
const user = await this.deps.userRepository.findByUuid({ uuid: parsedFields.host.userUuid });
|
||||
const hostName = user?.name || "Unknown";
|
||||
const hostFieldValue = `${hostName}:${parsedFields.host.noShow.new ? "Yes" : "No"}`;
|
||||
displayFields.push({ labelKey: "Host", value: hostFieldValue });
|
||||
}
|
||||
|
||||
return displayFields;
|
||||
}
|
||||
|
||||
getDisplayJson({ storedData }: GetDisplayJsonParams): NoShowUpdatedAuditDisplayData {
|
||||
const { fields } = this.parseStored({ version: storedData.version, fields: storedData.fields });
|
||||
const parsedFields = fields as NoShowUpdatedAuditData;
|
||||
return {
|
||||
...(this.isHostSet(parsedFields)
|
||||
? {
|
||||
hostNoShow: parsedFields.host.noShow.new,
|
||||
previousHostNoShow: parsedFields.host.noShow.old,
|
||||
}
|
||||
: {}),
|
||||
...(this.isAttendeesNoShowSet(parsedFields)
|
||||
? {
|
||||
attendeesNoShow: parsedFields.attendeesNoShow,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type NoShowUpdatedAuditData = z.infer<typeof fieldsSchemaV1>;
|
||||
|
||||
export type NoShowUpdatedAuditDisplayData = {
|
||||
hostNoShow?: boolean;
|
||||
previousHostNoShow?: boolean | null;
|
||||
attendeesNoShow?: Array<{ attendeeEmail: string; noShow: { old: boolean | null; new: boolean } }> | null;
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import { z } from "zod";
|
||||
import { StringChangeSchema } from "../common/changeSchemas";
|
||||
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
|
||||
import type {
|
||||
BaseStoredAuditData,
|
||||
GetDisplayFieldsParams,
|
||||
GetDisplayJsonParams,
|
||||
GetDisplayTitleParams,
|
||||
IAuditActionService,
|
||||
@@ -123,7 +123,7 @@ export class ReassignmentAuditActionService implements IAuditActionService {
|
||||
};
|
||||
}
|
||||
|
||||
async getDisplayFields(storedData: BaseStoredAuditData): Promise<
|
||||
async getDisplayFields({ storedData }: GetDisplayFieldsParams): Promise<
|
||||
Array<{
|
||||
labelKey: string;
|
||||
valueKey: string;
|
||||
|
||||
+2
-2
@@ -279,7 +279,7 @@ describe("ReassignmentAuditActionService", () => {
|
||||
|
||||
mockUserRepository.findByUuid.mockResolvedValue({ uuid: "organizer-old", name: "Previous Host" });
|
||||
|
||||
const result = await service.getDisplayFields(storedData);
|
||||
const result = await service.getDisplayFields({ storedData });
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
@@ -305,7 +305,7 @@ describe("ReassignmentAuditActionService", () => {
|
||||
|
||||
mockUserRepository.findByUuid.mockResolvedValue({ uuid: "organizer-old", name: "Previous Host" });
|
||||
|
||||
const result = await service.getDisplayFields(storedData);
|
||||
const result = await service.getDisplayFields({ storedData });
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
|
||||
@@ -12,7 +12,7 @@ export type BookingAuditType = "RECORD_CREATED" | "RECORD_UPDATED" | "RECORD_DEL
|
||||
* They are reserved in the enum for potential future use but should not appear in audit logs.
|
||||
* Use the CREATED action to capture initial booking status instead.
|
||||
*/
|
||||
export type BookingAuditAction = "CREATED" | "CANCELLED" | "ACCEPTED" | "REJECTED" | "PENDING" | "AWAITING_HOST" | "RESCHEDULED" | "ATTENDEE_ADDED" | "ATTENDEE_REMOVED" | "REASSIGNMENT" | "LOCATION_CHANGED" | "HOST_NO_SHOW_UPDATED" | "ATTENDEE_NO_SHOW_UPDATED" | "RESCHEDULE_REQUESTED" | "SEAT_BOOKED" | "SEAT_RESCHEDULED"
|
||||
export type BookingAuditAction = "CREATED" | "CANCELLED" | "ACCEPTED" | "REJECTED" | "PENDING" | "AWAITING_HOST" | "RESCHEDULED" | "ATTENDEE_ADDED" | "ATTENDEE_REMOVED" | "REASSIGNMENT" | "LOCATION_CHANGED" | "NO_SHOW_UPDATED" | "RESCHEDULE_REQUESTED" | "SEAT_BOOKED" | "SEAT_RESCHEDULED"
|
||||
export type BookingAuditCreateInput = {
|
||||
bookingUid: string;
|
||||
actorId: string;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { IAttendeeRepository } from "@calcom/features/bookings/repositories/IAttendeeRepository";
|
||||
import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import type { IAuditActionService } from "../actions/IAuditActionService";
|
||||
import type { BookingAuditAction } from "../repository/IBookingAuditRepository";
|
||||
@@ -8,12 +9,11 @@ import { RescheduledAuditActionService, type RescheduledAuditData } from "../act
|
||||
import { AcceptedAuditActionService, type AcceptedAuditData } from "../actions/AcceptedAuditActionService";
|
||||
import { RescheduleRequestedAuditActionService, type RescheduleRequestedAuditData } from "../actions/RescheduleRequestedAuditActionService";
|
||||
import { AttendeeAddedAuditActionService, type AttendeeAddedAuditData } from "../actions/AttendeeAddedAuditActionService";
|
||||
import { HostNoShowUpdatedAuditActionService, type HostNoShowUpdatedAuditData } from "../actions/HostNoShowUpdatedAuditActionService";
|
||||
import { NoShowUpdatedAuditActionService, type NoShowUpdatedAuditData } from "../actions/NoShowUpdatedAuditActionService";
|
||||
import { RejectedAuditActionService, type RejectedAuditData } from "../actions/RejectedAuditActionService";
|
||||
import { AttendeeRemovedAuditActionService, type AttendeeRemovedAuditData } from "../actions/AttendeeRemovedAuditActionService";
|
||||
import { ReassignmentAuditActionService, type ReassignmentAuditData } from "../actions/ReassignmentAuditActionService";
|
||||
import { LocationChangedAuditActionService, type LocationChangedAuditData } from "../actions/LocationChangedAuditActionService";
|
||||
import { AttendeeNoShowUpdatedAuditActionService, type AttendeeNoShowUpdatedAuditData } from "../actions/AttendeeNoShowUpdatedAuditActionService";
|
||||
import { SeatBookedAuditActionService, type SeatBookedAuditData } from "../actions/SeatBookedAuditActionService";
|
||||
import { SeatRescheduledAuditActionService, type SeatRescheduledAuditData } from "../actions/SeatRescheduledAuditActionService";
|
||||
|
||||
@@ -28,12 +28,11 @@ export type AuditActionData =
|
||||
| AcceptedAuditData
|
||||
| RescheduleRequestedAuditData
|
||||
| AttendeeAddedAuditData
|
||||
| HostNoShowUpdatedAuditData
|
||||
| NoShowUpdatedAuditData
|
||||
| RejectedAuditData
|
||||
| AttendeeRemovedAuditData
|
||||
| ReassignmentAuditData
|
||||
| LocationChangedAuditData
|
||||
| AttendeeNoShowUpdatedAuditData
|
||||
| SeatBookedAuditData
|
||||
| SeatRescheduledAuditData;
|
||||
|
||||
@@ -45,6 +44,7 @@ export type AuditActionData =
|
||||
* code duplication between consumer and viewer services.
|
||||
*/
|
||||
interface BookingAuditActionServiceRegistryDeps {
|
||||
attendeeRepository: IAttendeeRepository;
|
||||
userRepository: UserRepository;
|
||||
}
|
||||
|
||||
@@ -59,12 +59,11 @@ export class BookingAuditActionServiceRegistry {
|
||||
["ACCEPTED", new AcceptedAuditActionService()],
|
||||
["RESCHEDULE_REQUESTED", new RescheduleRequestedAuditActionService()],
|
||||
["ATTENDEE_ADDED", new AttendeeAddedAuditActionService()],
|
||||
["HOST_NO_SHOW_UPDATED", new HostNoShowUpdatedAuditActionService()],
|
||||
["NO_SHOW_UPDATED", new NoShowUpdatedAuditActionService({ attendeeRepository: deps.attendeeRepository, userRepository: deps.userRepository })],
|
||||
["REJECTED", new RejectedAuditActionService()],
|
||||
["ATTENDEE_REMOVED", new AttendeeRemovedAuditActionService()],
|
||||
["REASSIGNMENT", new ReassignmentAuditActionService(deps.userRepository)],
|
||||
["LOCATION_CHANGED", new LocationChangedAuditActionService()],
|
||||
["ATTENDEE_NO_SHOW_UPDATED", new AttendeeNoShowUpdatedAuditActionService()],
|
||||
["SEAT_BOOKED", new SeatBookedAuditActionService()],
|
||||
["SEAT_RESCHEDULED", new SeatRescheduledAuditActionService()],
|
||||
];
|
||||
|
||||
+3
-14
@@ -4,12 +4,11 @@ import type { Actor, BookingAuditContext } from "../dto/types";
|
||||
import type { ActionSource } from "../types/actionSource";
|
||||
import { AcceptedAuditActionService } from "../actions/AcceptedAuditActionService";
|
||||
import { AttendeeAddedAuditActionService } from "../actions/AttendeeAddedAuditActionService";
|
||||
import { AttendeeNoShowUpdatedAuditActionService } from "../actions/AttendeeNoShowUpdatedAuditActionService";
|
||||
import { AttendeeRemovedAuditActionService } from "../actions/AttendeeRemovedAuditActionService";
|
||||
import { CancelledAuditActionService } from "../actions/CancelledAuditActionService";
|
||||
import { CreatedAuditActionService } from "../actions/CreatedAuditActionService";
|
||||
import { HostNoShowUpdatedAuditActionService } from "../actions/HostNoShowUpdatedAuditActionService";
|
||||
import { LocationChangedAuditActionService } from "../actions/LocationChangedAuditActionService";
|
||||
import { NoShowUpdatedAuditActionService } from "../actions/NoShowUpdatedAuditActionService";
|
||||
import { ReassignmentAuditActionService } from "../actions/ReassignmentAuditActionService";
|
||||
import { RejectedAuditActionService } from "../actions/RejectedAuditActionService";
|
||||
import { RescheduleRequestedAuditActionService } from "../actions/RescheduleRequestedAuditActionService";
|
||||
@@ -91,13 +90,13 @@ export interface BookingAuditProducerService {
|
||||
context?: BookingAuditContext;
|
||||
}): Promise<void>;
|
||||
|
||||
queueHostNoShowUpdatedAudit(params: {
|
||||
queueNoShowUpdatedAudit(params: {
|
||||
bookingUid: string;
|
||||
actor: Actor;
|
||||
organizationId: number | null;
|
||||
source: ActionSource;
|
||||
operationId?: string | null;
|
||||
data: z.infer<typeof HostNoShowUpdatedAuditActionService.latestFieldsSchema>;
|
||||
data: z.infer<typeof NoShowUpdatedAuditActionService.latestFieldsSchema>;
|
||||
context?: BookingAuditContext;
|
||||
}): Promise<void>;
|
||||
|
||||
@@ -141,16 +140,6 @@ export interface BookingAuditProducerService {
|
||||
context?: BookingAuditContext;
|
||||
}): Promise<void>;
|
||||
|
||||
queueAttendeeNoShowUpdatedAudit(params: {
|
||||
bookingUid: string;
|
||||
actor: Actor;
|
||||
organizationId: number | null;
|
||||
source: ActionSource;
|
||||
operationId?: string | null;
|
||||
data: z.infer<typeof AttendeeNoShowUpdatedAuditActionService.latestFieldsSchema>;
|
||||
context?: BookingAuditContext;
|
||||
}): Promise<void>;
|
||||
|
||||
queueSeatBookedAudit(params: {
|
||||
bookingUid: string;
|
||||
actor: Actor;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { JsonValue } from "@calcom/types/Json";
|
||||
|
||||
import logger from "@calcom/lib/logger";
|
||||
import type { IAttendeeRepository } from "@calcom/features/bookings/repositories/IAttendeeRepository";
|
||||
import type { IFeaturesRepository } from "@calcom/features/flags/features.repository.interface";
|
||||
import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
|
||||
@@ -21,6 +22,7 @@ interface BookingAuditTaskConsumerDeps {
|
||||
bookingAuditRepository: IBookingAuditRepository;
|
||||
auditActorRepository: IAuditActorRepository;
|
||||
featuresRepository: IFeaturesRepository;
|
||||
attendeeRepository: IAttendeeRepository;
|
||||
userRepository: UserRepository;
|
||||
}
|
||||
|
||||
@@ -69,16 +71,21 @@ export class BookingAuditTaskConsumer {
|
||||
private readonly bookingAuditRepository: IBookingAuditRepository;
|
||||
private readonly auditActorRepository: IAuditActorRepository;
|
||||
private readonly featuresRepository: IFeaturesRepository;
|
||||
private readonly attendeeRepository: IAttendeeRepository;
|
||||
private readonly userRepository: UserRepository;
|
||||
|
||||
constructor(private readonly deps: BookingAuditTaskConsumerDeps) {
|
||||
this.bookingAuditRepository = deps.bookingAuditRepository;
|
||||
this.auditActorRepository = deps.auditActorRepository;
|
||||
this.featuresRepository = deps.featuresRepository;
|
||||
this.attendeeRepository = deps.attendeeRepository;
|
||||
this.userRepository = deps.userRepository;
|
||||
|
||||
// Centralized registry for all action services
|
||||
this.actionServiceRegistry = new BookingAuditActionServiceRegistry({ userRepository: this.userRepository });
|
||||
this.actionServiceRegistry = new BookingAuditActionServiceRegistry({
|
||||
attendeeRepository: this.attendeeRepository,
|
||||
userRepository: this.userRepository,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -366,8 +373,7 @@ export class BookingAuditTaskConsumer {
|
||||
case "ATTENDEE_REMOVED":
|
||||
case "REASSIGNMENT":
|
||||
case "LOCATION_CHANGED":
|
||||
case "HOST_NO_SHOW_UPDATED":
|
||||
case "ATTENDEE_NO_SHOW_UPDATED":
|
||||
case "NO_SHOW_UPDATED":
|
||||
case "SEAT_BOOKED":
|
||||
case "SEAT_RESCHEDULED":
|
||||
return "RECORD_UPDATED";
|
||||
|
||||
@@ -12,12 +12,11 @@ import { makeActorById, buildActorEmail } from "../makeActor";
|
||||
import type { IAuditActorRepository } from "../repository/IAuditActorRepository";
|
||||
import { AcceptedAuditActionService } from "../actions/AcceptedAuditActionService";
|
||||
import { AttendeeAddedAuditActionService } from "../actions/AttendeeAddedAuditActionService";
|
||||
import { AttendeeNoShowUpdatedAuditActionService } from "../actions/AttendeeNoShowUpdatedAuditActionService";
|
||||
import { AttendeeRemovedAuditActionService } from "../actions/AttendeeRemovedAuditActionService";
|
||||
import { CancelledAuditActionService } from "../actions/CancelledAuditActionService";
|
||||
import { CreatedAuditActionService } from "../actions/CreatedAuditActionService";
|
||||
import { HostNoShowUpdatedAuditActionService } from "../actions/HostNoShowUpdatedAuditActionService";
|
||||
import { LocationChangedAuditActionService } from "../actions/LocationChangedAuditActionService";
|
||||
import { NoShowUpdatedAuditActionService } from "../actions/NoShowUpdatedAuditActionService";
|
||||
import { ReassignmentAuditActionService } from "../actions/ReassignmentAuditActionService";
|
||||
import { RejectedAuditActionService } from "../actions/RejectedAuditActionService";
|
||||
import { RescheduleRequestedAuditActionService } from "../actions/RescheduleRequestedAuditActionService";
|
||||
@@ -216,17 +215,18 @@ export class BookingAuditTaskerProducerService implements BookingAuditProducerSe
|
||||
});
|
||||
}
|
||||
|
||||
async queueHostNoShowUpdatedAudit(params: {
|
||||
async queueNoShowUpdatedAudit(params: {
|
||||
bookingUid: string;
|
||||
actor: Actor;
|
||||
organizationId: number | null;
|
||||
source: ActionSource;
|
||||
operationId?: string | null;
|
||||
data: z.infer<typeof HostNoShowUpdatedAuditActionService.latestFieldsSchema>;
|
||||
data: z.infer<typeof NoShowUpdatedAuditActionService.latestFieldsSchema>;
|
||||
context?: BookingAuditContext;
|
||||
}): Promise<void> {
|
||||
await this.queueTask({
|
||||
...params,
|
||||
action: HostNoShowUpdatedAuditActionService.TYPE,
|
||||
action: NoShowUpdatedAuditActionService.TYPE,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -286,20 +286,6 @@ export class BookingAuditTaskerProducerService implements BookingAuditProducerSe
|
||||
});
|
||||
}
|
||||
|
||||
async queueAttendeeNoShowUpdatedAudit(params: {
|
||||
bookingUid: string;
|
||||
actor: Actor;
|
||||
organizationId: number | null;
|
||||
source: ActionSource;
|
||||
operationId?: string | null;
|
||||
data: z.infer<typeof AttendeeNoShowUpdatedAuditActionService.latestFieldsSchema>;
|
||||
}): Promise<void> {
|
||||
await this.queueTask({
|
||||
...params,
|
||||
action: AttendeeNoShowUpdatedAuditActionService.TYPE,
|
||||
});
|
||||
}
|
||||
|
||||
async queueSeatBookedAudit(params: {
|
||||
bookingUid: string;
|
||||
actor: Actor;
|
||||
|
||||
@@ -1,58 +1,63 @@
|
||||
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 { ISimpleLogger } from "@calcom/features/di/shared/services/logger.service";
|
||||
import type { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
|
||||
import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
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";
|
||||
import { getAppNameFromSlug } from "../getAppNameFromSlug";
|
||||
import type { AuditActorType } from "../repository/IAuditActorRepository";
|
||||
import type {
|
||||
BookingAuditAction,
|
||||
BookingAuditType,
|
||||
BookingAuditWithActor,
|
||||
IBookingAuditRepository,
|
||||
} from "../repository/IBookingAuditRepository";
|
||||
import type { ActionSource } from "../types/actionSource";
|
||||
import { BookingAuditAccessService } from "./BookingAuditAccessService";
|
||||
import { BookingAuditActionServiceRegistry } from "./BookingAuditActionServiceRegistry";
|
||||
|
||||
interface BookingAuditViewerServiceDeps {
|
||||
bookingAuditRepository: IBookingAuditRepository;
|
||||
userRepository: UserRepository;
|
||||
bookingRepository: BookingRepository;
|
||||
membershipRepository: MembershipRepository;
|
||||
attendeeRepository: IAttendeeRepository;
|
||||
log: ISimpleLogger;
|
||||
credentialRepository: CredentialRepository;
|
||||
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; value?: string; values?: string[] }> | null;
|
||||
actor: {
|
||||
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;
|
||||
};
|
||||
impersonatedBy?: {
|
||||
displayName: string;
|
||||
displayEmail: string | null;
|
||||
displayAvatar: string | null;
|
||||
} | null;
|
||||
hasError?: boolean;
|
||||
type: AuditActorType;
|
||||
userUuid: string | null;
|
||||
attendeeId: number | null;
|
||||
name: string | null;
|
||||
createdAt: Date;
|
||||
displayName: string;
|
||||
displayEmail: string | null;
|
||||
displayAvatar: string | null;
|
||||
};
|
||||
impersonatedBy?: {
|
||||
displayName: string;
|
||||
displayEmail: string | null;
|
||||
displayAvatar: string | null;
|
||||
} | null;
|
||||
hasError?: boolean;
|
||||
};
|
||||
|
||||
export type DisplayBookingAuditLog = EnrichedAuditLog;
|
||||
@@ -61,337 +66,346 @@ export type DisplayBookingAuditLog = EnrichedAuditLog;
|
||||
* 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"];
|
||||
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 });
|
||||
}
|
||||
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({
|
||||
attendeeRepository: this.attendeeRepository,
|
||||
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: DisplayBookingAuditLog[] }> {
|
||||
const { bookingUid, userId, userTimeZone, organizationId } = params;
|
||||
await this.accessService.assertPermissions({
|
||||
bookingUid,
|
||||
userId,
|
||||
organizationId
|
||||
});
|
||||
/**
|
||||
* 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: DisplayBookingAuditLog[] }> {
|
||||
const { bookingUid, userId, userTimeZone, organizationId } = params;
|
||||
await this.accessService.assertPermissions({
|
||||
bookingUid,
|
||||
userId,
|
||||
organizationId,
|
||||
});
|
||||
|
||||
const auditLogs = await this.bookingAuditRepository.findAllForBooking(bookingUid);
|
||||
const auditLogs = await this.bookingAuditRepository.findAllForBooking(bookingUid);
|
||||
|
||||
const enrichedAuditLogs = await Promise.all(
|
||||
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);
|
||||
|
||||
// Check if this booking was created from a reschedule
|
||||
if (fromRescheduleUid) {
|
||||
const rescheduledFromLog = await this.buildRescheduledFromLog({
|
||||
fromRescheduleUid,
|
||||
currentBookingUid: bookingUid,
|
||||
userTimeZone,
|
||||
});
|
||||
if (rescheduledFromLog) {
|
||||
enrichedAuditLogs.push(rescheduledFromLog);
|
||||
}
|
||||
const enrichedAuditLogs = await Promise.all(
|
||||
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);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
bookingUid: params.bookingUid,
|
||||
auditLogs: enrichedAuditLogs,
|
||||
};
|
||||
}
|
||||
const fromRescheduleUid = await this.bookingRepository.getFromRescheduleUid(bookingUid);
|
||||
|
||||
/**
|
||||
* 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
|
||||
? await actionService.getDisplayFields(parsedData)
|
||||
: null;
|
||||
|
||||
const impersonatedBy = await this.enrichImpersonator(log.context);
|
||||
|
||||
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,
|
||||
},
|
||||
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
|
||||
* to show "rescheduled from" context for the current booking.
|
||||
*/
|
||||
private async buildRescheduledFromLog({
|
||||
// Check if this booking was created from a reschedule
|
||||
if (fromRescheduleUid) {
|
||||
const rescheduledFromLog = await this.buildRescheduledFromLog({
|
||||
fromRescheduleUid,
|
||||
currentBookingUid,
|
||||
currentBookingUid: bookingUid,
|
||||
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,
|
||||
}),
|
||||
};
|
||||
});
|
||||
if (rescheduledFromLog) {
|
||||
enrichedAuditLogs.push(rescheduledFromLog);
|
||||
}
|
||||
}
|
||||
|
||||
private async enrichImpersonator(context: BookingAuditContext | null): Promise<{
|
||||
displayName: string;
|
||||
displayEmail: string | null;
|
||||
displayAvatar: string | null;
|
||||
} | null> {
|
||||
if (!context?.impersonatedBy) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
bookingUid: params.bookingUid,
|
||||
auditLogs: enrichedAuditLogs,
|
||||
};
|
||||
}
|
||||
|
||||
const impersonatorUser = await this.userRepository.findByUuid({ uuid: context.impersonatedBy });
|
||||
if (!impersonatorUser) {
|
||||
/**
|
||||
* 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
|
||||
? await actionService.getDisplayFields({ storedData: parsedData })
|
||||
: null;
|
||||
|
||||
const impersonatedBy = await this.enrichImpersonator(log.context);
|
||||
|
||||
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,
|
||||
},
|
||||
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
|
||||
* 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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
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
|
||||
*/
|
||||
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: "Deleted User",
|
||||
displayEmail: null,
|
||||
displayAvatar: null,
|
||||
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: impersonatorUser.name || impersonatorUser.email,
|
||||
displayEmail: impersonatorUser.email,
|
||||
displayAvatar: impersonatorUser.avatarUrl || null,
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+332
@@ -0,0 +1,332 @@
|
||||
import { describe, expect, it, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import type { BookingStatus } from "@calcom/prisma/enums";
|
||||
import type { BookingAuditTaskConsumer } from "../BookingAuditTaskConsumer";
|
||||
import type { BookingAuditViewerService } from "../BookingAuditViewerService";
|
||||
import { makeUserActor } from "../../makeActor";
|
||||
import { getBookingAuditTaskConsumer } from "../../../di/BookingAuditTaskConsumer.container";
|
||||
import { getBookingAuditViewerService } from "../../../di/BookingAuditViewerService.container";
|
||||
import {
|
||||
createTestUser,
|
||||
createTestOrganization,
|
||||
createTestMembership,
|
||||
createTestEventType,
|
||||
createTestBooking,
|
||||
enableFeatureForOrganization,
|
||||
cleanupTestData,
|
||||
} from "./integration-utils";
|
||||
|
||||
describe("No-Show Updated Action Integration", () => {
|
||||
let bookingAuditTaskConsumer: BookingAuditTaskConsumer;
|
||||
let bookingAuditViewerService: BookingAuditViewerService;
|
||||
|
||||
let testData: {
|
||||
owner: { id: number; uuid: string; email: string };
|
||||
attendee: { id: number; email: string };
|
||||
organization: { id: number };
|
||||
eventType: { id: number };
|
||||
booking: { uid: string; startTime: Date; endTime: Date; status: BookingStatus };
|
||||
attendeeIds: number[];
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
bookingAuditTaskConsumer = getBookingAuditTaskConsumer();
|
||||
bookingAuditViewerService = getBookingAuditViewerService();
|
||||
|
||||
const owner = await createTestUser({ name: "Test Host User" });
|
||||
const organization = await createTestOrganization();
|
||||
await createTestMembership(owner.id, organization.id);
|
||||
await enableFeatureForOrganization(organization.id, "booking-audit");
|
||||
const eventType = await createTestEventType(owner.id);
|
||||
const attendee = await createTestUser({ name: "Test Attendee" });
|
||||
|
||||
const booking = await createTestBooking(owner.id, eventType.id, {
|
||||
attendees: [
|
||||
{
|
||||
email: attendee.email,
|
||||
name: attendee.name || "Test Attendee",
|
||||
timeZone: "UTC",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Get the attendee IDs from the booking
|
||||
const { prisma } = await import("@calcom/prisma");
|
||||
const bookingWithAttendees = await prisma.booking.findUnique({
|
||||
where: { uid: booking.uid },
|
||||
include: { attendees: true },
|
||||
});
|
||||
|
||||
testData = {
|
||||
owner: { id: owner.id, uuid: owner.uuid, email: owner.email },
|
||||
attendee: { id: attendee.id, email: attendee.email },
|
||||
organization: { id: organization.id },
|
||||
eventType: { id: eventType.id },
|
||||
booking: {
|
||||
uid: booking.uid,
|
||||
startTime: booking.startTime,
|
||||
endTime: booking.endTime,
|
||||
status: booking.status,
|
||||
},
|
||||
attendeeIds: bookingWithAttendees?.attendees.map((a) => a.id) || [],
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (!testData) return;
|
||||
|
||||
await cleanupTestData({
|
||||
bookingUid: testData.booking?.uid,
|
||||
userUuids: testData.owner?.uuid ? [testData.owner.uuid] : [],
|
||||
attendeeEmails: testData.attendee?.email ? [testData.attendee.email] : [],
|
||||
eventTypeId: testData.eventType?.id,
|
||||
organizationId: testData.organization?.id,
|
||||
userIds: [testData.owner?.id, testData.attendee?.id].filter((id): id is number => id !== undefined),
|
||||
featureSlug: "booking-audit",
|
||||
});
|
||||
});
|
||||
|
||||
describe("when host is marked as no-show", () => {
|
||||
it("should create audit record with host field containing userUuid and noShow", async () => {
|
||||
const actor = makeUserActor(testData.owner.uuid);
|
||||
|
||||
await bookingAuditTaskConsumer.onBookingAction({
|
||||
bookingUid: testData.booking.uid,
|
||||
actor,
|
||||
action: "NO_SHOW_UPDATED",
|
||||
source: "WEBAPP",
|
||||
operationId: `op-${Date.now()}`,
|
||||
data: {
|
||||
// New schema: host contains userUuid and noShow change
|
||||
host: {
|
||||
userUuid: testData.owner.uuid,
|
||||
noShow: { old: null, new: true },
|
||||
},
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
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.bookingUid).toBe(testData.booking.uid);
|
||||
expect(result.auditLogs).toHaveLength(1);
|
||||
|
||||
const auditLog = result.auditLogs[0];
|
||||
expect(auditLog.bookingUid).toBe(testData.booking.uid);
|
||||
expect(auditLog.action).toBe("NO_SHOW_UPDATED");
|
||||
expect(auditLog.type).toBe("RECORD_UPDATED");
|
||||
|
||||
const displayData = auditLog.displayJson as Record<string, unknown>;
|
||||
expect(displayData).toBeDefined();
|
||||
expect(displayData.hostNoShow).toBe(true);
|
||||
expect(displayData.previousHostNoShow).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when attendees are marked as no-show", () => {
|
||||
it("should create audit record with attendeesNoShow array", async () => {
|
||||
const actor = makeUserActor(testData.owner.uuid);
|
||||
|
||||
const attendeesNoShow = [
|
||||
{ attendeeEmail: testData.attendee.email, noShow: { old: null, new: true } },
|
||||
];
|
||||
|
||||
await bookingAuditTaskConsumer.onBookingAction({
|
||||
bookingUid: testData.booking.uid,
|
||||
actor,
|
||||
action: "NO_SHOW_UPDATED",
|
||||
source: "WEBAPP",
|
||||
operationId: `op-${Date.now()}`,
|
||||
data: {
|
||||
attendeesNoShow,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
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.bookingUid).toBe(testData.booking.uid);
|
||||
expect(result.auditLogs).toHaveLength(1);
|
||||
|
||||
const auditLog = result.auditLogs[0];
|
||||
expect(auditLog.action).toBe("NO_SHOW_UPDATED");
|
||||
expect(auditLog.type).toBe("RECORD_UPDATED");
|
||||
|
||||
const displayData = auditLog.displayJson as Record<string, unknown>;
|
||||
expect(displayData).toBeDefined();
|
||||
expect(displayData.attendeesNoShow).toBeDefined();
|
||||
|
||||
const storedAttendeesNoShow = displayData.attendeesNoShow as Array<{
|
||||
attendeeEmail: string;
|
||||
noShow: { old: boolean | null; new: boolean };
|
||||
}>;
|
||||
expect(storedAttendeesNoShow).toHaveLength(1);
|
||||
expect(storedAttendeesNoShow[0].attendeeEmail).toBe(testData.attendee.email);
|
||||
expect(storedAttendeesNoShow[0].noShow.old).toBe(null);
|
||||
expect(storedAttendeesNoShow[0].noShow.new).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle multiple attendees marked as no-show", async () => {
|
||||
// Create a second attendee for this test
|
||||
const { prisma } = await import("@calcom/prisma");
|
||||
const secondAttendeeEmail = `second-attendee-${Date.now()}@example.com`;
|
||||
const secondAttendee = await prisma.attendee.create({
|
||||
data: {
|
||||
email: secondAttendeeEmail,
|
||||
name: "Second Attendee",
|
||||
timeZone: "UTC",
|
||||
bookingId: (await prisma.booking.findUnique({ where: { uid: testData.booking.uid } }))!.id,
|
||||
},
|
||||
});
|
||||
|
||||
const actor = makeUserActor(testData.owner.uuid);
|
||||
|
||||
const attendeesNoShow = [
|
||||
{ attendeeEmail: testData.attendee.email, noShow: { old: null, new: true } },
|
||||
{ attendeeEmail: secondAttendeeEmail, noShow: { old: false, new: true } },
|
||||
];
|
||||
|
||||
await bookingAuditTaskConsumer.onBookingAction({
|
||||
bookingUid: testData.booking.uid,
|
||||
actor,
|
||||
action: "NO_SHOW_UPDATED",
|
||||
source: "WEBAPP",
|
||||
operationId: `op-${Date.now()}`,
|
||||
data: {
|
||||
attendeesNoShow,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
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 displayData = result.auditLogs[0].displayJson as Record<string, unknown>;
|
||||
const storedAttendeesNoShow = displayData.attendeesNoShow as Array<{
|
||||
attendeeEmail: string;
|
||||
noShow: { old: boolean | null; new: boolean };
|
||||
}>;
|
||||
|
||||
expect(storedAttendeesNoShow).toHaveLength(2);
|
||||
const firstAttendee = storedAttendeesNoShow.find((a) => a.attendeeEmail === testData.attendee.email);
|
||||
const secondAttendeeData = storedAttendeesNoShow.find((a) => a.attendeeEmail === secondAttendeeEmail);
|
||||
expect(firstAttendee?.noShow).toEqual({ old: null, new: true });
|
||||
expect(secondAttendeeData?.noShow).toEqual({ old: false, new: true });
|
||||
|
||||
// Cleanup the second attendee
|
||||
await prisma.attendee.delete({ where: { id: secondAttendee.id } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("when both host and attendees are marked as no-show", () => {
|
||||
it("should create single audit record with both host and attendeesNoShow fields", async () => {
|
||||
const actor = makeUserActor(testData.owner.uuid);
|
||||
|
||||
await bookingAuditTaskConsumer.onBookingAction({
|
||||
bookingUid: testData.booking.uid,
|
||||
actor,
|
||||
action: "NO_SHOW_UPDATED",
|
||||
source: "API_V2",
|
||||
operationId: `op-${Date.now()}`,
|
||||
data: {
|
||||
host: {
|
||||
userUuid: testData.owner.uuid,
|
||||
noShow: { old: null, new: true },
|
||||
},
|
||||
attendeesNoShow: [
|
||||
{ attendeeEmail: testData.attendee.email, noShow: { old: null, new: true } },
|
||||
],
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
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.action).toBe("NO_SHOW_UPDATED");
|
||||
expect(auditLog.source).toBe("API_V2");
|
||||
|
||||
const displayData = auditLog.displayJson as Record<string, unknown>;
|
||||
|
||||
expect(displayData.hostNoShow).toBe(true);
|
||||
expect(displayData.previousHostNoShow).toBe(null);
|
||||
|
||||
const storedAttendeesNoShow = displayData.attendeesNoShow as Array<{
|
||||
attendeeEmail: string;
|
||||
noShow: { old: boolean | null; new: boolean };
|
||||
}>;
|
||||
expect(storedAttendeesNoShow).toHaveLength(1);
|
||||
expect(storedAttendeesNoShow[0].attendeeEmail).toBe(testData.attendee.email);
|
||||
expect(storedAttendeesNoShow[0].noShow).toEqual({ old: null, new: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("schema validation with array format", () => {
|
||||
it("should accept attendeesNoShow data with array format", async () => {
|
||||
const actor = makeUserActor(testData.owner.uuid);
|
||||
|
||||
const dataWithArrayFormat = {
|
||||
attendeesNoShow: [
|
||||
{ attendeeEmail: testData.attendee.email, noShow: { old: null, new: true } },
|
||||
],
|
||||
};
|
||||
|
||||
await bookingAuditTaskConsumer.onBookingAction({
|
||||
bookingUid: testData.booking.uid,
|
||||
actor,
|
||||
action: "NO_SHOW_UPDATED",
|
||||
source: "WEBAPP",
|
||||
operationId: `op-${Date.now()}`,
|
||||
data: dataWithArrayFormat,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
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 displayData = result.auditLogs[0].displayJson as Record<string, unknown>;
|
||||
expect(displayData.attendeesNoShow).toBeDefined();
|
||||
|
||||
const storedAttendeesNoShow = displayData.attendeesNoShow as Array<{
|
||||
attendeeEmail: string;
|
||||
noShow: { old: boolean | null; new: boolean };
|
||||
}>;
|
||||
expect(storedAttendeesNoShow).toHaveLength(1);
|
||||
expect(storedAttendeesNoShow[0].attendeeEmail).toBe(testData.attendee.email);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,11 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// This is the schema for DB value, here we use UNKNOWN in case client didn't pass an explicit action source.
|
||||
export const ActionSourceSchema = z.enum(["API_V1", "API_V2", "WEBAPP", "WEBHOOK", "MAGIC_LINK", "UNKNOWN"]);
|
||||
// SYSTEM is used for background jobs (tasker tasks, trigger.dev, etc.)
|
||||
export const ActionSourceSchema = z.enum(["API_V1", "API_V2", "WEBAPP", "WEBHOOK", "MAGIC_LINK", "SYSTEM", "UNKNOWN"]);
|
||||
export type ActionSource = z.infer<typeof ActionSourceSchema>;
|
||||
|
||||
// We don't keep UNKNOWN here because we don't want clients to pass UNKNOWN.
|
||||
export const ValidActionSourceSchema = z.enum(["API_V1", "API_V2", "WEBAPP", "WEBHOOK", "MAGIC_LINK"]);
|
||||
// SYSTEM is used for background jobs (tasker tasks, trigger.dev, etc.)
|
||||
export const ValidActionSourceSchema = z.enum(["API_V1", "API_V2", "WEBAPP", "WEBHOOK", "MAGIC_LINK", "SYSTEM"]);
|
||||
export type ValidActionSource = z.infer<typeof ValidActionSourceSchema>;
|
||||
@@ -14,12 +14,11 @@ const BookingAuditActionSchema = z.enum([
|
||||
"CANCELLED",
|
||||
"RESCHEDULE_REQUESTED",
|
||||
"ATTENDEE_ADDED",
|
||||
"HOST_NO_SHOW_UPDATED",
|
||||
"REJECTED",
|
||||
"ATTENDEE_REMOVED",
|
||||
"REASSIGNMENT",
|
||||
"LOCATION_CHANGED",
|
||||
"ATTENDEE_NO_SHOW_UPDATED",
|
||||
"NO_SHOW_UPDATED",
|
||||
"SEAT_BOOKED",
|
||||
"SEAT_RESCHEDULED",
|
||||
]);
|
||||
|
||||
@@ -7,8 +7,7 @@ import type { AttendeeAddedAuditData } from "@calcom/features/booking-audit/lib/
|
||||
import type { AttendeeRemovedAuditData } from "@calcom/features/booking-audit/lib/actions/AttendeeRemovedAuditActionService";
|
||||
import type { ReassignmentAuditData } from "@calcom/features/booking-audit/lib/actions/ReassignmentAuditActionService";
|
||||
import type { LocationChangedAuditData } from "@calcom/features/booking-audit/lib/actions/LocationChangedAuditActionService";
|
||||
import type { HostNoShowUpdatedAuditData } from "@calcom/features/booking-audit/lib/actions/HostNoShowUpdatedAuditActionService";
|
||||
import type { AttendeeNoShowUpdatedAuditData } from "@calcom/features/booking-audit/lib/actions/AttendeeNoShowUpdatedAuditActionService";
|
||||
import type { NoShowUpdatedAuditData } from "@calcom/features/booking-audit/lib/actions/NoShowUpdatedAuditActionService";
|
||||
import type { SeatBookedAuditData } from "@calcom/features/booking-audit/lib/actions/SeatBookedAuditActionService";
|
||||
import type { SeatRescheduledAuditData } from "@calcom/features/booking-audit/lib/actions/SeatRescheduledAuditActionService";
|
||||
import type { CreatedAuditData } from "@calcom/features/booking-audit/lib/actions/CreatedAuditActionService";
|
||||
@@ -58,12 +57,11 @@ type OnBookingAcceptedParams = BaseBookingEventParams<AcceptedAuditData>;
|
||||
type OnBookingCancelledParams = BaseBookingEventParams<CancelledAuditData>;
|
||||
type OnRescheduleRequestedParams = BaseBookingEventParams<RescheduleRequestedAuditData>;
|
||||
type OnAttendeeAddedParams = BaseBookingEventParams<AttendeeAddedAuditData>;
|
||||
type OnHostNoShowUpdatedParams = BaseBookingEventParams<HostNoShowUpdatedAuditData>;
|
||||
type OnNoShowUpdatedParams = BaseBookingEventParams<NoShowUpdatedAuditData>;
|
||||
type OnBookingRejectedParams = BaseBookingEventParams<RejectedAuditData>;
|
||||
type OnAttendeeRemovedParams = BaseBookingEventParams<AttendeeRemovedAuditData>;
|
||||
type OnReassignmentParams = BaseBookingEventParams<ReassignmentAuditData>;
|
||||
type OnLocationChangedParams = BaseBookingEventParams<LocationChangedAuditData>;
|
||||
type OnAttendeeNoShowUpdatedParams = BaseBookingEventParams<AttendeeNoShowUpdatedAuditData>;
|
||||
type OnSeatBookedParams = BaseBookingEventParams<SeatBookedAuditData>;
|
||||
type OnSeatRescheduledParams = BaseBookingEventParams<SeatRescheduledAuditData>;
|
||||
|
||||
@@ -195,9 +193,9 @@ export class BookingEventHandlerService {
|
||||
});
|
||||
}
|
||||
|
||||
async onHostNoShowUpdated(params: OnHostNoShowUpdatedParams) {
|
||||
async onNoShowUpdated(params: OnNoShowUpdatedParams) {
|
||||
const { bookingUid, actor, organizationId, auditData, source, operationId, context } = params;
|
||||
await this.bookingAuditProducerService.queueHostNoShowUpdatedAudit({
|
||||
await this.bookingAuditProducerService.queueNoShowUpdatedAudit({
|
||||
bookingUid,
|
||||
actor,
|
||||
organizationId,
|
||||
@@ -264,19 +262,6 @@ export class BookingEventHandlerService {
|
||||
}
|
||||
}
|
||||
|
||||
async onAttendeeNoShowUpdated(params: OnAttendeeNoShowUpdatedParams) {
|
||||
const { bookingUid, actor, organizationId, auditData, source, operationId, context } = params;
|
||||
await this.bookingAuditProducerService.queueAttendeeNoShowUpdatedAudit({
|
||||
bookingUid,
|
||||
actor,
|
||||
organizationId,
|
||||
source,
|
||||
operationId,
|
||||
data: auditData,
|
||||
context,
|
||||
});
|
||||
}
|
||||
|
||||
async onSeatBooked(params: OnSeatBookedParams) {
|
||||
const { bookingUid, actor, organizationId, auditData, source, operationId, context } = params;
|
||||
await this.bookingAuditProducerService.queueSeatBookedAudit({
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
import type { IAttendeeRepository } from "./IAttendeeRepository";
|
||||
|
||||
const safeSelect = {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
locale: true,
|
||||
timeZone: true,
|
||||
phoneNumber: true,
|
||||
bookingId: true,
|
||||
noShow: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Prisma-based implementation of IAttendeeRepository
|
||||
*
|
||||
@@ -22,10 +32,24 @@ export class AttendeeRepository implements IAttendeeRepository {
|
||||
return attendee;
|
||||
}
|
||||
|
||||
async findByBookingIdAndSeatReference(
|
||||
bookingId: number,
|
||||
seatReferenceUid: string
|
||||
): Promise<{ email: string }[]> {
|
||||
async findByIds({ ids }: { ids: number[] }): Promise<{ id: number; name: string; email: string }[]> {
|
||||
return this.prismaClient.attendee.findMany({
|
||||
where: { id: { in: ids } },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findByBookingIdAndSeatReference({
|
||||
bookingId,
|
||||
seatReferenceUid,
|
||||
}: {
|
||||
bookingId: number;
|
||||
seatReferenceUid: string;
|
||||
}): Promise<{ email: string }[]> {
|
||||
return this.prismaClient.attendee.findMany({
|
||||
where: {
|
||||
bookingId,
|
||||
@@ -36,4 +60,84 @@ export class AttendeeRepository implements IAttendeeRepository {
|
||||
select: { email: true },
|
||||
});
|
||||
}
|
||||
|
||||
async findByBookingUidAndEmails({
|
||||
bookingUid,
|
||||
emails,
|
||||
}: {
|
||||
bookingUid: string;
|
||||
emails: string[];
|
||||
}): Promise<{ id: number; email: string; noShow: boolean | null }[]> {
|
||||
return this.prismaClient.attendee.findMany({
|
||||
where: {
|
||||
booking: { uid: bookingUid },
|
||||
email: { in: emails },
|
||||
},
|
||||
select: { id: true, email: true, noShow: true },
|
||||
});
|
||||
}
|
||||
|
||||
async updateNoShow({
|
||||
where: { attendeeId },
|
||||
data: { noShow },
|
||||
}: {
|
||||
where: { attendeeId: number };
|
||||
data: { noShow: boolean };
|
||||
}): Promise<{ noShow: boolean | null; email: string }> {
|
||||
return this.prismaClient.attendee.update({
|
||||
where: { id: attendeeId },
|
||||
data: { noShow },
|
||||
select: { noShow: true, email: true },
|
||||
});
|
||||
}
|
||||
|
||||
async findByBookingId(bookingId: number): Promise<
|
||||
{
|
||||
id: number;
|
||||
email: string;
|
||||
name: string;
|
||||
locale: string | null;
|
||||
timeZone: string;
|
||||
phoneNumber: string | null;
|
||||
bookingId: number | null;
|
||||
noShow: boolean | null;
|
||||
}[]
|
||||
> {
|
||||
return this.prismaClient.attendee.findMany({
|
||||
where: { bookingId },
|
||||
select: safeSelect,
|
||||
});
|
||||
}
|
||||
|
||||
async updateManyNoShowByBookingIdAndEmails({
|
||||
where: { bookingId, emails },
|
||||
data: { noShow },
|
||||
}: {
|
||||
where: { bookingId: number; emails: string[] };
|
||||
data: { noShow: boolean };
|
||||
}): Promise<{ count: number }> {
|
||||
return this.prismaClient.attendee.updateMany({
|
||||
where: {
|
||||
bookingId,
|
||||
email: { in: emails },
|
||||
},
|
||||
data: { noShow },
|
||||
});
|
||||
}
|
||||
|
||||
async updateManyNoShowByBookingIdExcludingEmails({
|
||||
where: { bookingId, excludeEmails },
|
||||
data: { noShow },
|
||||
}: {
|
||||
where: { bookingId: number; excludeEmails: string[] };
|
||||
data: { noShow: boolean };
|
||||
}): Promise<{ count: number }> {
|
||||
return this.prismaClient.attendee.updateMany({
|
||||
where: {
|
||||
bookingId,
|
||||
email: { notIn: excludeEmails },
|
||||
},
|
||||
data: { noShow },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -5,5 +5,6 @@
|
||||
*/
|
||||
export interface IAttendeeRepository {
|
||||
findById(id: number): Promise<{ name: string; email: string } | null>;
|
||||
findByIds({ ids }: { ids: number[] }): Promise<{ id: number; name: string; email: string }[]>;
|
||||
}
|
||||
|
||||
@@ -66,10 +66,10 @@ export async function sendAwaitingPaymentEmail(payload: string): Promise<void> {
|
||||
let attendeesToEmail = evt.attendees;
|
||||
if (attendeeSeatId) {
|
||||
const attendeeRepository = new AttendeeRepository(prisma);
|
||||
const seatAttendees = await attendeeRepository.findByBookingIdAndSeatReference(
|
||||
const seatAttendees = await attendeeRepository.findByBookingIdAndSeatReference({
|
||||
bookingId,
|
||||
attendeeSeatId
|
||||
);
|
||||
seatReferenceUid: attendeeSeatId,
|
||||
});
|
||||
const seatEmails = new Set(seatAttendees.map((a) => (a.email || "").toLowerCase()));
|
||||
attendeesToEmail = evt.attendees.filter((attendee) =>
|
||||
seatEmails.has((attendee.email || "").toLowerCase())
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "public"."BookingAuditSource" ADD VALUE 'system';
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
-- AlterEnum: Remove old values and add new unified value
|
||||
-- Rename old type to backup
|
||||
ALTER TYPE "BookingAuditAction" RENAME TO "BookingAuditAction_old";
|
||||
|
||||
-- Create new type without deprecated values, with new unified value
|
||||
CREATE TYPE "BookingAuditAction" AS ENUM (
|
||||
'created',
|
||||
'cancelled',
|
||||
'accepted',
|
||||
'rejected',
|
||||
'pending',
|
||||
'awaiting_host',
|
||||
'rescheduled',
|
||||
'attendee_added',
|
||||
'attendee_removed',
|
||||
'reassignment',
|
||||
'location_changed',
|
||||
'no_show_updated',
|
||||
'reschedule_requested',
|
||||
'seat_booked',
|
||||
'seat_rescheduled'
|
||||
);
|
||||
|
||||
-- Update the column to use the new type, converting deprecated values to the new unified value
|
||||
ALTER TABLE "BookingAudit" ALTER COLUMN "action" TYPE "BookingAuditAction" USING (
|
||||
CASE
|
||||
WHEN "action"::text IN ('host_no_show_updated', 'attendee_no_show_updated') THEN 'no_show_updated'::text
|
||||
ELSE "action"::text
|
||||
END
|
||||
)::"BookingAuditAction";
|
||||
|
||||
-- Drop the old type
|
||||
DROP TYPE "BookingAuditAction_old";
|
||||
@@ -2789,8 +2789,7 @@ enum BookingAuditAction {
|
||||
LOCATION_CHANGED @map("location_changed")
|
||||
|
||||
// No-show tracking
|
||||
HOST_NO_SHOW_UPDATED @map("host_no_show_updated")
|
||||
ATTENDEE_NO_SHOW_UPDATED @map("attendee_no_show_updated")
|
||||
NO_SHOW_UPDATED @map("no_show_updated")
|
||||
|
||||
// Rescheduling
|
||||
RESCHEDULE_REQUESTED @map("reschedule_requested")
|
||||
@@ -2805,6 +2804,7 @@ enum BookingAuditSource {
|
||||
API_V2 @map("api_v2")
|
||||
WEBAPP @map("webapp")
|
||||
WEBHOOK @map("webhook")
|
||||
SYSTEM @map("system")
|
||||
MAGIC_LINK @map("magic_link")
|
||||
UNKNOWN @map("unknown")
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user