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:
authored and GitHub committed 2026-01-29 08:47:26 -03:00
1 parent 2ec9e903ce
commit 2802310268
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",
+3 -3
View File
@@ -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,
},
});
@@ -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;
@@ -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()],
];
@@ -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,
};
}
}
}
}
@@ -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';
@@ -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";
+2 -2
View File
@@ -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")
}