diff --git a/apps/web/modules/booking-audit/components/BookingHistory.tsx b/apps/web/modules/booking-audit/components/BookingHistory.tsx index 48d0c1d8cc..e464adb61f 100644 --- a/apps/web/modules/booking-audit/components/BookingHistory.tsx +++ b/apps/web/modules/booking-audit/components/BookingHistory.tsx @@ -36,7 +36,7 @@ type AuditLog = { source: string; displayJson?: Record | 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 = { 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 = { @@ -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 ( + + {field.values.map((v, i) => ( + + {v} + + ))} + + ); + } + + return <>{field.value ?? (field.valueKey ? t(field.valueKey) : "")}; +} + function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) { const { t } = useLocale(); const [expandedLogIds, setExpandedLogIds] = useState>(new Set()); @@ -279,10 +304,10 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
- - {t(field.labelKey)} + {t(field.labelKey)} + + - {t(field.valueKey)}
)) : 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 = diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 33954c14b5..6ddc5fc9ef 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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}}", "assignment_type": "Assignment", diff --git a/packages/features/booking-audit/README.md b/packages/features/booking-audit/README.md index 54dd0b2127..df54160952 100644 --- a/packages/features/booking-audit/README.md +++ b/packages/features/booking-audit/README.md @@ -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 diff --git a/packages/features/booking-audit/di/BookingAuditTaskConsumer.module.ts b/packages/features/booking-audit/di/BookingAuditTaskConsumer.module.ts index 48e75d070c..01db014301 100644 --- a/packages/features/booking-audit/di/BookingAuditTaskConsumer.module.ts +++ b/packages/features/booking-audit/di/BookingAuditTaskConsumer.module.ts @@ -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, }, }); diff --git a/packages/features/booking-audit/lib/actions/AttendeeNoShowUpdatedAuditActionService.ts b/packages/features/booking-audit/lib/actions/AttendeeNoShowUpdatedAuditActionService.ts deleted file mode 100644 index 5128fca841..0000000000 --- a/packages/features/booking-audit/lib/actions/AttendeeNoShowUpdatedAuditActionService.ts +++ /dev/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"; - -/** - * 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 { - 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; - -export type AttendeeNoShowUpdatedAuditDisplayData = { - noShowAttendee: boolean; - previousNoShowAttendee: boolean | null; -}; diff --git a/packages/features/booking-audit/lib/actions/HostNoShowUpdatedAuditActionService.ts b/packages/features/booking-audit/lib/actions/HostNoShowUpdatedAuditActionService.ts deleted file mode 100644 index 8a3877afd8..0000000000 --- a/packages/features/booking-audit/lib/actions/HostNoShowUpdatedAuditActionService.ts +++ /dev/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 { - 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; - -export type HostNoShowUpdatedAuditDisplayData = { - noShowHost: boolean; - previousNoShowHost: boolean | null; -}; diff --git a/packages/features/booking-audit/lib/actions/IAuditActionService.ts b/packages/features/booking-audit/lib/actions/IAuditActionService.ts index a23aaba775..e0d5f0d6a6 100644 --- a/packages/features/booking-audit/lib/actions/IAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/IAuditActionService.ts @@ -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") */ export type TranslationWithParams = { - key: string; - params?: Record; - components?: TranslationComponent[]; + key: string; + params?: Record; + components?: TranslationComponent[]; }; /** * This is agnostic of the action and is common for all actions */ export type BaseStoredAuditData = { - version: number; - fields: Record; + version: number; + fields: Record; }; 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; + /** + * 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; - /** - * 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; + /** + * 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; - /** - * 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>; + /** + * 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; - }; + isMigrated: boolean; + /** + * Always set, either migrated or original + */ + latestData: Record; + }; } diff --git a/packages/features/booking-audit/lib/actions/NoShowUpdatedAuditActionService.ts b/packages/features/booking-audit/lib/actions/NoShowUpdatedAuditActionService.ts new file mode 100644 index 0000000000..433eebaa94 --- /dev/null +++ b/packages/features/booking-audit/lib/actions/NoShowUpdatedAuditActionService.ts @@ -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 { + return fields.host !== undefined; + } + + private isAttendeesNoShowSet( + fields: NoShowUpdatedAuditData + ): fields is Ensure { + return fields.attendeesNoShow !== undefined; + } + + async getDisplayTitle({ storedData }: GetDisplayTitleParams): Promise { + 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; + +export type NoShowUpdatedAuditDisplayData = { + hostNoShow?: boolean; + previousHostNoShow?: boolean | null; + attendeesNoShow?: Array<{ attendeeEmail: string; noShow: { old: boolean | null; new: boolean } }> | null; +}; diff --git a/packages/features/booking-audit/lib/actions/ReassignmentAuditActionService.ts b/packages/features/booking-audit/lib/actions/ReassignmentAuditActionService.ts index 7dc3f8c3cc..05c73748aa 100644 --- a/packages/features/booking-audit/lib/actions/ReassignmentAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/ReassignmentAuditActionService.ts @@ -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; diff --git a/packages/features/booking-audit/lib/actions/__tests__/ReassignmentAuditActionService.test.ts b/packages/features/booking-audit/lib/actions/__tests__/ReassignmentAuditActionService.test.ts index ce7f01cea3..1366f91fb3 100644 --- a/packages/features/booking-audit/lib/actions/__tests__/ReassignmentAuditActionService.test.ts +++ b/packages/features/booking-audit/lib/actions/__tests__/ReassignmentAuditActionService.test.ts @@ -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([ { diff --git a/packages/features/booking-audit/lib/repository/IBookingAuditRepository.ts b/packages/features/booking-audit/lib/repository/IBookingAuditRepository.ts index e8cc8bd2fd..38767bce36 100644 --- a/packages/features/booking-audit/lib/repository/IBookingAuditRepository.ts +++ b/packages/features/booking-audit/lib/repository/IBookingAuditRepository.ts @@ -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; diff --git a/packages/features/booking-audit/lib/service/BookingAuditActionServiceRegistry.ts b/packages/features/booking-audit/lib/service/BookingAuditActionServiceRegistry.ts index 5ace2104dd..d18d64875b 100644 --- a/packages/features/booking-audit/lib/service/BookingAuditActionServiceRegistry.ts +++ b/packages/features/booking-audit/lib/service/BookingAuditActionServiceRegistry.ts @@ -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()], ]; diff --git a/packages/features/booking-audit/lib/service/BookingAuditProducerService.interface.ts b/packages/features/booking-audit/lib/service/BookingAuditProducerService.interface.ts index 1f4ff249f8..239d6d0d1b 100644 --- a/packages/features/booking-audit/lib/service/BookingAuditProducerService.interface.ts +++ b/packages/features/booking-audit/lib/service/BookingAuditProducerService.interface.ts @@ -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; - queueHostNoShowUpdatedAudit(params: { + queueNoShowUpdatedAudit(params: { bookingUid: string; actor: Actor; organizationId: number | null; source: ActionSource; operationId?: string | null; - data: z.infer; + data: z.infer; context?: BookingAuditContext; }): Promise; @@ -141,16 +140,6 @@ export interface BookingAuditProducerService { context?: BookingAuditContext; }): Promise; - queueAttendeeNoShowUpdatedAudit(params: { - bookingUid: string; - actor: Actor; - organizationId: number | null; - source: ActionSource; - operationId?: string | null; - data: z.infer; - context?: BookingAuditContext; - }): Promise; - queueSeatBookedAudit(params: { bookingUid: string; actor: Actor; diff --git a/packages/features/booking-audit/lib/service/BookingAuditTaskConsumer.ts b/packages/features/booking-audit/lib/service/BookingAuditTaskConsumer.ts index c04b669c45..4ff51ebc6b 100644 --- a/packages/features/booking-audit/lib/service/BookingAuditTaskConsumer.ts +++ b/packages/features/booking-audit/lib/service/BookingAuditTaskConsumer.ts @@ -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"; diff --git a/packages/features/booking-audit/lib/service/BookingAuditTaskerProducerService.ts b/packages/features/booking-audit/lib/service/BookingAuditTaskerProducerService.ts index c1e00ffcd9..c800a5c9c5 100644 --- a/packages/features/booking-audit/lib/service/BookingAuditTaskerProducerService.ts +++ b/packages/features/booking-audit/lib/service/BookingAuditTaskerProducerService.ts @@ -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; + data: z.infer; + context?: BookingAuditContext; }): Promise { 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; - }): Promise { - await this.queueTask({ - ...params, - action: AttendeeNoShowUpdatedAuditActionService.TYPE, - }); - } - async queueSeatBookedAudit(params: { bookingUid: string; actor: Actor; diff --git a/packages/features/booking-audit/lib/service/BookingAuditViewerService.ts b/packages/features/booking-audit/lib/service/BookingAuditViewerService.ts index 4b43fd792f..a10b8045fc 100644 --- a/packages/features/booking-audit/lib/service/BookingAuditViewerService.ts +++ b/packages/features/booking-audit/lib/service/BookingAuditViewerService.ts @@ -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 | 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 | 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 { - 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 { - 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 { + 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 { + 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, + }; + } } + } } diff --git a/packages/features/booking-audit/lib/service/__tests__/no-show-updated-action.integration-test.ts b/packages/features/booking-audit/lib/service/__tests__/no-show-updated-action.integration-test.ts new file mode 100644 index 0000000000..75fc38b8fa --- /dev/null +++ b/packages/features/booking-audit/lib/service/__tests__/no-show-updated-action.integration-test.ts @@ -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; + 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; + 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; + 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; + + 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; + 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); + }); + }); +}); diff --git a/packages/features/booking-audit/lib/types/actionSource.ts b/packages/features/booking-audit/lib/types/actionSource.ts index 2762afd2e3..c03a743d38 100644 --- a/packages/features/booking-audit/lib/types/actionSource.ts +++ b/packages/features/booking-audit/lib/types/actionSource.ts @@ -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; // 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; diff --git a/packages/features/booking-audit/lib/types/bookingAuditTask.ts b/packages/features/booking-audit/lib/types/bookingAuditTask.ts index 7324196250..3ebc9a61f7 100644 --- a/packages/features/booking-audit/lib/types/bookingAuditTask.ts +++ b/packages/features/booking-audit/lib/types/bookingAuditTask.ts @@ -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", ]); diff --git a/packages/features/bookings/lib/onBookingEvents/BookingEventHandlerService.ts b/packages/features/bookings/lib/onBookingEvents/BookingEventHandlerService.ts index 7fbee68f52..2999afb04c 100644 --- a/packages/features/bookings/lib/onBookingEvents/BookingEventHandlerService.ts +++ b/packages/features/bookings/lib/onBookingEvents/BookingEventHandlerService.ts @@ -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; type OnBookingCancelledParams = BaseBookingEventParams; type OnRescheduleRequestedParams = BaseBookingEventParams; type OnAttendeeAddedParams = BaseBookingEventParams; -type OnHostNoShowUpdatedParams = BaseBookingEventParams; +type OnNoShowUpdatedParams = BaseBookingEventParams; type OnBookingRejectedParams = BaseBookingEventParams; type OnAttendeeRemovedParams = BaseBookingEventParams; type OnReassignmentParams = BaseBookingEventParams; type OnLocationChangedParams = BaseBookingEventParams; -type OnAttendeeNoShowUpdatedParams = BaseBookingEventParams; type OnSeatBookedParams = BaseBookingEventParams; type OnSeatRescheduledParams = BaseBookingEventParams; @@ -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({ diff --git a/packages/features/bookings/repositories/AttendeeRepository.ts b/packages/features/bookings/repositories/AttendeeRepository.ts index 7230f41552..a89ebb44de 100644 --- a/packages/features/bookings/repositories/AttendeeRepository.ts +++ b/packages/features/bookings/repositories/AttendeeRepository.ts @@ -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 }, + }); + } } diff --git a/packages/features/bookings/repositories/IAttendeeRepository.ts b/packages/features/bookings/repositories/IAttendeeRepository.ts index f35cbf3378..223b6f4a7d 100644 --- a/packages/features/bookings/repositories/IAttendeeRepository.ts +++ b/packages/features/bookings/repositories/IAttendeeRepository.ts @@ -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 }[]>; } diff --git a/packages/features/tasker/tasks/sendAwaitingPaymentEmail.ts b/packages/features/tasker/tasks/sendAwaitingPaymentEmail.ts index 08f6e79c03..6485fb7291 100644 --- a/packages/features/tasker/tasks/sendAwaitingPaymentEmail.ts +++ b/packages/features/tasker/tasks/sendAwaitingPaymentEmail.ts @@ -66,10 +66,10 @@ export async function sendAwaitingPaymentEmail(payload: string): Promise { 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()) diff --git a/packages/prisma/migrations/20260119113000_add_system_source/migration.sql b/packages/prisma/migrations/20260119113000_add_system_source/migration.sql new file mode 100644 index 0000000000..f36210d8fc --- /dev/null +++ b/packages/prisma/migrations/20260119113000_add_system_source/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "public"."BookingAuditSource" ADD VALUE 'system'; diff --git a/packages/prisma/migrations/20260120093500_add_no_show_updated_audit_action/migration.sql b/packages/prisma/migrations/20260120093500_add_no_show_updated_audit_action/migration.sql new file mode 100644 index 0000000000..0a2be5c5ba --- /dev/null +++ b/packages/prisma/migrations/20260120093500_add_no_show_updated_audit_action/migration.sql @@ -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"; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 36d488b149..ba0b594067 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -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") }