feat: extract core booking audit infrastructure from PR 25125 (#25729)

* feat: extract core booking audit infrastructure from PR 25125

This PR contains only the core booking audit infrastructure changes from PR 25125,
excluding integration changes with booking flows.

Included:
- All packages/features/booking-audit/* (core audit services, actions, repository)
- packages/features/di/containers/BookingAuditViewerService.container.ts
- packages/features/tasker/tasker.ts (audit task types)
- packages/features/bookings/lib/types/actor.ts (actor types for audit)
- packages/features/bookings/repositories/BookingRepository.ts (getFromRescheduleUid method)
- apps/web/modules/booking/logs/views/booking-logs-view.tsx (UI for viewing audit logs)
- apps/web/public/static/locales/en/common.json (translations)

Excluded (integration changes):
- packages/trpc/server/* (tRPC handlers)
- packages/features/ee/round-robin/* (round-robin integration)
- packages/features/bookings/lib/handleCancelBooking.ts
- packages/features/bookings/lib/handleConfirmation.ts
- packages/features/bookings/lib/onBookingEvents/BookingEventHandlerService.ts
- packages/features/bookings/lib/service/RegularBookingService.ts
- apps/api/v2/* (API v2 integration)

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: make booking audit interfaces backwards-compatible with main

- Add queueAudit method back to BookingAuditProducerService interface for backwards compatibility
- Implement queueAudit method in BookingAuditTaskerProducerService
- Make userTimeZone parameter optional in BookingAuditViewerService
- Add BookingAuditTaskProducerActionData type for legacy queueAudit method
- Use any generics in BookingAuditActionServiceRegistry (matching PR 25125)
- Fix type assertions in BookingAuditTaskConsumer

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix switch eslint and ts

* feat: enhance BookingAuditViewerService with logging and type improvements

- Added ISimpleLogger dependency to BookingAuditViewerService for better error handling.
- Updated actor type in enriched audit logs to use AuditActorType for improved type safety.
- Replaced console.error with logger for error reporting when no rescheduled log is found.

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
This commit is contained in:
Hariom Balhara
2025-12-15 10:51:30 +00:00
committed by GitHub
co-authored by Devin AI Udit Takkar
parent 4e1d05a7e7
commit 2218a45d83
34 changed files with 1194 additions and 329 deletions
@@ -1,13 +1,15 @@
import { TaskerService } from "@/lib/services/tasker.service";
import { Logger } from "@/lib/logger.bridge";
import { Injectable } from "@nestjs/common";
import { BookingAuditTaskerProducerService } from "@calcom/platform-libraries/bookings";
@Injectable()
export class BookingAuditProducerService extends BookingAuditTaskerProducerService {
constructor(taskerService: TaskerService) {
constructor(taskerService: TaskerService, bridgeLogger: Logger) {
super({
tasker: taskerService.getTasker(),
log: bridgeLogger,
});
}
}
@@ -3,6 +3,7 @@
*/
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -12,32 +13,47 @@ import { Button } from "@calcom/ui/components/button";
import { Icon } from "@calcom/ui/components/icon";
import { SkeletonText } from "@calcom/ui/components/skeleton";
import { FilterSearchField, Select } from "@calcom/ui/components/form";
import { Avatar } from "@calcom/ui/components/avatar";
import ServerTrans from "@calcom/lib/components/ServerTrans";
import type { AuditActorType } from "@calcom/features/booking-audit/lib/repository/IAuditActorRepository";
interface BookingLogsViewProps {
bookingUid: string;
}
type TranslationComponent = {
type: "link";
href: string;
};
type TranslationWithParams = {
key: string;
params?: Record<string, string | number>;
components?: TranslationComponent[];
};
type AuditLog = {
id: string;
action: string;
type: string;
timestamp: string;
data: Record<string, unknown> | null;
actionDisplayTitle: TranslationWithParams;
displayFields?: Array<{ labelKey: string; valueKey: string }>;
actor: {
type: string;
type: AuditActorType;
displayName: string | null;
displayEmail: string | null;
displayAvatar: string | null;
};
};
interface BookingLogsFiltersProps {
searchTerm: string;
onSearchChange: (value: string) => void;
typeFilter: string | null;
onTypeFilterChange: (value: string | null) => void;
actorFilter: string | null;
onActorFilterChange: (value: string | null) => void;
typeOptions: Array<{ label: string; value: string }>;
actorOptions: Array<{ label: string; value: string }>;
actorFilter: AuditActorType | null;
onActorFilterChange: (value: AuditActorType | null) => void;
actorOptions: Array<{ label: string; value: AuditActorType }>;
}
interface BookingLogsTimelineProps {
@@ -70,14 +86,26 @@ const getActionIcon = (action: string) => {
}
};
const getActorRoleLabel = (actorType: AuditActorType): string | null => {
switch (actorType) {
case "GUEST":
return "Guest";
case "ATTENDEE":
return "Attendee";
case "SYSTEM":
return null;
case "USER":
return null;
default:
return null;
}
};
function BookingLogsFilters({
searchTerm,
onSearchChange,
typeFilter,
onTypeFilterChange,
actorFilter,
onActorFilterChange,
typeOptions,
actorOptions,
}: BookingLogsFiltersProps) {
const { t } = useLocale();
@@ -93,25 +121,13 @@ function BookingLogsFilters({
/>
</div>
<div className="min-w-[140px]">
<Select
size="sm"
value={typeFilter ? { label: `${t("type")}: ${t(`audit_action.${typeFilter.toLowerCase()}`)}`, value: typeFilter } : { label: `${t("type")}: ${t("all")}`, value: "" }}
onChange={(option) => {
if (!option) return;
onTypeFilterChange(option.value || null);
}}
options={[{ label: `${t("type")}: ${t("all")}`, value: "" }, ...typeOptions.map(opt => ({ ...opt, label: `${t("type")}: ${opt.label}` }))]}
/>
</div>
<div className="min-w-[140px]">
<Select
size="sm"
value={actorFilter ? { label: `${t("actor")}: ${actorFilter}`, value: actorFilter } : { label: `${t("actor")}: ${t("all")}`, value: "" }}
onChange={(option) => {
if (!option) return;
onActorFilterChange(option.value || null);
onActorFilterChange((option.value as AuditActorType) || null);
}}
options={[{ label: `${t("actor")}: ${t("all")}`, value: "" }, ...actorOptions.map(opt => ({ ...opt, label: `${t("actor")}: ${opt.label}` }))]}
/>
@@ -120,6 +136,68 @@ function BookingLogsFilters({
);
}
/**
* Renders the action display title with support for Trans component interpolation
* Handles translations with embedded components (e.g., links) for proper i18n support
*/
function ActionTitle({ actionDisplayTitle }: { actionDisplayTitle: TranslationWithParams }) {
const { t } = useLocale();
if (actionDisplayTitle.components?.length) {
return (
<ServerTrans
t={t}
i18nKey={actionDisplayTitle.key}
values={actionDisplayTitle.params}
components={actionDisplayTitle.components.map((comp) =>
comp.type === "link" ? (
<Link
key={comp.href}
href={comp.href}
className="text-emphasis underline hover:no-underline"
/>
) : (
<span key={comp.href} />
)
)}
/>
);
}
return <>{t(actionDisplayTitle.key, actionDisplayTitle.params)}</>;
}
interface JsonViewerProps {
data: Record<string, unknown> | null;
}
function JsonViewer({ data }: JsonViewerProps) {
if (!data || Object.keys(data).length === 0) {
return null;
}
const jsonString = JSON.stringify(data, null, 2);
const lines = jsonString.split("\n");
const lineCount = lines.length;
const lineNumberWidth = Math.max(2, Math.ceil(Math.log10(lineCount)) + 1);
return (
<div className="bg-default p-3 rounded-md text-[10px] overflow-x-auto font-mono">
{lines.map((line, idx) => (
<div key={idx} className="flex gap-2">
<span
className="text-subtle select-none text-right shrink-0"
style={{ minWidth: `${lineNumberWidth}ch` }}
>
{idx + 1}
</span>
<span className="text-default whitespace-pre">{line || " "}</span>
</div>
))}
</div>
);
}
function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
const { t } = useLocale();
const [expandedLogIds, setExpandedLogIds] = useState<Set<string>>(new Set());
@@ -157,8 +235,8 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
{logs.map((log, index) => {
const isLast = index === logs.length - 1;
const isExpanded = expandedLogIds.has(log.id);
const actionDisplay = t(`audit_action.${log.action.toLowerCase()}`);
const showJson = showJsonMap[log.id] || false;
const actorRole = getActorRoleLabel(log.actor.type);
return (
<div key={log.id} className="flex gap-1">
@@ -178,78 +256,84 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-emphasis leading-4">
{actionDisplay}
<ActionTitle actionDisplayTitle={log.actionDisplayTitle} />
</h3>
<div className="flex items-center gap-1 mt-1 text-xs text-subtle">
<span>{log.actor.displayName}</span>
{log.actor.displayAvatar && (
<Avatar
size="xs"
imageSrc={log.actor.displayAvatar}
alt={log.actor.displayName || ""}
/>
)}
<span>
{log.actor.displayName}
{actorRole && ` (${actorRole})`}
</span>
<span>•</span>
<span>{dayjs(log.timestamp).fromNow()}</span>
</div>
</div>
<Button
color="minimal"
size="sm"
variant="icon"
StartIcon="ellipsis"
className="h-6 w-6"
/>
</div>
</div>
<div className="px-3">
<div className="bg-muted rounded-[10px] py-1">
<Button
color="minimal"
size="sm"
onClick={() => toggleExpand(log.id)}
StartIcon={isExpanded ? "chevron-down" : "chevron-right"}
className="w-full justify-start text-xs font-medium text-subtle h-6">
{isExpanded ? t("hide_details") : t("show_details")}
</Button>
<div className="bg-muted rounded-lg border border-muted">
<div className="py-1">
<Button
color="minimal"
size="sm"
onClick={() => toggleExpand(log.id)}
StartIcon={isExpanded ? "chevron-down" : "chevron-right"}
className="justify-start text-xs font-medium text-subtle h-6">
{isExpanded ? t("hide_details") : t("show_details")}
</Button>
</div>
{isExpanded && (
<div className="bg-default rounded-lg m-0.5 text-xs">
{/* Render displayFields if available, otherwise show type */}
{log.displayFields && log.displayFields.length > 0 ? (
log.displayFields.map((field, idx) => (
<div key={idx} className="flex items-start gap-2 py-2 border-b px-3 border-subtle">
<span className="font-medium text-emphasis min-w-[80px]">{t(field.labelKey)}</span>
<span className="text-[#096638] font-medium">{t(field.valueKey)}</span>
</div>
))
) : null}
<div className="flex items-start gap-2 py-2 border-b px-3 border-subtle">
<span className="font-medium text-emphasis min-w-[80px]">{t("actor")}</span>
<span className="text-default">{log.actor.displayName || log.actor.type}</span>
</div>
<div className="flex items-start gap-2 py-2 px-3 border-b border-subtle">
<span className="font-medium text-emphasis min-w-[80px]">
{t("timestamp")}
</span>
<span className="text-default">
{dayjs(log.timestamp).format("YYYY-MM-DD HH:mm:ss")}
</span>
</div>
{log.data && Object.keys(log.data).length > 0 && (
<div>
<div className="flex flex-col items-start gap-2 py-1 px-3 border-b border-subtle">
<Button
color="minimal"
size="sm"
onClick={() => toggleJson(log.id)}
StartIcon={showJson ? "chevron-down" : "chevron-right"}
className="-ml-3 h-6 px-2 font-medium text-xs">
{t("json")}
</Button>
</div>
<div>
{showJson && <JsonViewer data={log.data} />}
</div>
</div>
)}
</div>
)}
</div>
</div>
{isExpanded && (
<div className="mt-2 px-3">
<div className="space-y-2 text-xs">
<div className="flex items-start gap-2">
<span className="font-medium text-emphasis min-w-[80px]">{t("type")}</span>
<span className="text-default">{log.type}</span>
</div>
<div className="flex items-start gap-2">
<span className="font-medium text-emphasis min-w-[80px]">{t("actor")}</span>
<span className="text-default">{log.actor.type}</span>
</div>
<div className="flex items-start gap-2">
<span className="font-medium text-emphasis min-w-[80px]">
{t("timestamp")}
</span>
<span className="text-default">
{dayjs(log.timestamp).format("YYYY-MM-DD HH:mm:ss")}
</span>
</div>
{log.data && Object.keys(log.data).length > 0 && (
<div className="pt-2">
<Button
color="minimal"
size="sm"
onClick={() => toggleJson(log.id)}
StartIcon={showJson ? "chevron-down" : "chevron-right"}
className="mb-1 h-6 px-0 font-medium">
{t("json")}
</Button>
{showJson && (
<pre className="bg-subtle p-3 rounded-md text-[10px] overflow-x-auto text-default font-mono border border-subtle">
{JSON.stringify(log.data, null, 2)}
</pre>
)}
</div>
)}
</div>
</div>
)}
</div>
</div>
);
@@ -261,8 +345,7 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
export default function BookingLogsView({ bookingUid }: BookingLogsViewProps) {
const router = useRouter();
const [searchTerm, setSearchTerm] = useState("");
const [typeFilter, setTypeFilter] = useState<string | null>(null);
const [actorFilter, setActorFilter] = useState<string | null>(null);
const [actorFilter, setActorFilter] = useState<AuditActorType | null>(null);
const { t } = useLocale();
const { data, isLoading, error } = trpc.viewer.bookings.getAuditLogs.useQuery({
bookingUid,
@@ -302,19 +385,12 @@ export default function BookingLogsView({ bookingUid }: BookingLogsViewProps) {
log.action.toLowerCase().includes(searchTerm.toLowerCase()) ||
log.actor.displayName?.toLowerCase().includes(searchTerm.toLowerCase());
const matchesType = !typeFilter || log.action === typeFilter;
const matchesActor = !actorFilter || log.actor.type === actorFilter;
return matchesSearch && matchesType && matchesActor;
return matchesSearch && matchesActor;
});
const uniqueTypes = Array.from(new Set(auditLogs.map((log) => log.action)));
const uniqueActorTypes = Array.from(new Set(auditLogs.map((log) => log.actor.type)));
const typeOptions = uniqueTypes.map((type) => ({
label: t(`audit_action.${type.toLowerCase()}`),
value: type,
}));
const uniqueActorTypes = Array.from(new Set(auditLogs.map((log) => log.actor.type))) as AuditActorType[];
const actorOptions = uniqueActorTypes.map((actorType) => ({
label: actorType,
@@ -326,11 +402,8 @@ export default function BookingLogsView({ bookingUid }: BookingLogsViewProps) {
<BookingLogsFilters
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
typeFilter={typeFilter}
onTypeFilterChange={setTypeFilter}
actorFilter={actorFilter}
onActorFilterChange={setActorFilter}
typeOptions={typeOptions}
actorOptions={actorOptions}
/>
+19 -2
View File
@@ -4114,8 +4114,25 @@
"saved": "Saved",
"booking_history": "Booking History",
"booking_history_description": "View the history of actions performed on this booking",
"audit_action": {
"created": "Created"
"booking_audit_action": {
"created": "Created",
"cancelled": "Cancelled",
"rescheduled": "Rescheduled {{oldDate}} -> <0>{{newDate}}</0>",
"rescheduled_from": "Rescheduled <0>{{oldDate}}</0> -> {{newDate}}",
"accepted": "Accepted",
"reschedule_requested": "Reschedule Requested",
"attendee_added": "Attendee Added",
"host_no_show_updated": "Host 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",
"type": "Assignment Type",
"assignmentType_manual": "Manual Assignment",
"assignmentType_roundRobin": "Round Robin Assignment"
},
"error_loading_booking_logs": "Error loading booking logs",
"no_audit_logs_found": "No audit logs found",
@@ -3,6 +3,7 @@ import { BOOKING_AUDIT_DI_TOKENS } from "@calcom/features/booking-audit/di/token
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 featuresRepositoryModuleLoader } from "@calcom/features/di/modules/Features";
import { moduleLoader as userRepositoryModuleLoader } from "@calcom/features/di/modules/User";
import { createModule, bindModuleToClassOnToken } from "../../di/di";
@@ -19,6 +20,7 @@ const loadModule = bindModuleToClassOnToken({
bookingAuditRepository: bookingAuditRepositoryModuleLoader,
auditActorRepository: auditActorRepositoryModuleLoader,
featuresRepository: featuresRepositoryModuleLoader,
userRepository: userRepositoryModuleLoader,
},
});
@@ -1,6 +1,7 @@
import { BookingAuditTaskerProducerService } from "@calcom/features/booking-audit/lib/service/BookingAuditTaskerProducerService";
import { BOOKING_AUDIT_DI_TOKENS } from "@calcom/features/booking-audit/di/tokens";
import { moduleLoader as taskerModuleLoader } from "@calcom/features/di/shared/services/tasker.service";
import { moduleLoader as loggerModuleLoader } from "@calcom/features/di/shared/services/logger.service";
import { createModule, bindModuleToClassOnToken } from "../../di/di";
@@ -15,6 +16,7 @@ const loadModule = bindModuleToClassOnToken({
classs: BookingAuditTaskerProducerService,
depsMap: {
tasker: taskerModuleLoader,
log: loggerModuleLoader,
},
});
@@ -2,6 +2,8 @@ import { BookingAuditViewerService } from "@calcom/features/booking-audit/lib/se
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 userRepositoryModuleLoader } from "@calcom/features/di/modules/User";
import { moduleLoader as bookingRepositoryModuleLoader } from "@calcom/features/di/modules/Booking";
import { moduleLoader as loggerModuleLoader } from "@calcom/features/di/shared/services/logger.service";
import { createModule, bindModuleToClassOnToken } from "../../di/di";
@@ -19,6 +21,8 @@ const loadModule = bindModuleToClassOnToken({
depsMap: {
bookingAuditRepository: bookingAuditRepositoryModuleLoader,
userRepository: userRepositoryModuleLoader,
bookingRepository: bookingRepositoryModuleLoader,
log: loggerModuleLoader,
},
});
@@ -1,6 +1,7 @@
import type { BookingStatus } from "@calcom/prisma/enums";
import { z } from "zod";
import { StringChangeSchema } from "../common/changeSchemas";
import { BookingStatusChangeSchema } from "../common/changeSchemas";
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
@@ -11,7 +12,7 @@ import type { IAuditActionService, TranslationWithParams } from "./IAuditActionS
// Module-level because it is passed to IAuditActionService type outside the class scope
const fieldsSchemaV1 = z.object({
status: StringChangeSchema,
status: BookingStatusChangeSchema,
});
export class AcceptedAuditActionService
@@ -59,11 +60,16 @@ export class AcceptedAuditActionService
return { isMigrated: false, latestData: validated };
}
async getDisplayTitle(): Promise<TranslationWithParams> {
async getDisplayTitle(_: { storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }; userTimeZone: string }): Promise<TranslationWithParams> {
return { key: "booking_audit_action.accepted" };
}
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): AcceptedAuditDisplayData {
getDisplayJson({
storedData,
}: {
storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> };
userTimeZone: string;
}): AcceptedAuditDisplayData {
const { fields } = storedData;
return {
previousStatus: fields.status.old ?? null,
@@ -75,6 +81,6 @@ export class AcceptedAuditActionService
export type AcceptedAuditData = z.infer<typeof fieldsSchemaV1>;
export type AcceptedAuditDisplayData = {
previousStatus: string | null;
newStatus: string | null;
previousStatus: BookingStatus | null;
newStatus: BookingStatus | null;
};
@@ -59,11 +59,16 @@ export class AttendeeAddedAuditActionService
return { isMigrated: false, latestData: validated };
}
async getDisplayTitle(): Promise<TranslationWithParams> {
async getDisplayTitle(_: { storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }; userTimeZone: string }): Promise<TranslationWithParams> {
return { key: "booking_audit_action.attendee_added" };
}
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): AttendeeAddedAuditDisplayData {
getDisplayJson({
storedData,
}: {
storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> };
userTimeZone: string;
}): AttendeeAddedAuditDisplayData {
const { fields } = storedData;
const previousAttendeesSet = new Set(fields.attendees.old ?? []);
const addedAttendees = fields.attendees.new.filter(
@@ -59,11 +59,16 @@ export class AttendeeNoShowUpdatedAuditActionService
return { isMigrated: false, latestData: validated };
}
async getDisplayTitle(): Promise<TranslationWithParams> {
async getDisplayTitle(_: { storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }; userTimeZone: string }): Promise<TranslationWithParams> {
return { key: "booking_audit_action.attendee_no_show_updated" };
}
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): AttendeeNoShowUpdatedAuditDisplayData {
getDisplayJson({
storedData,
}: {
storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> };
userTimeZone: string;
}): AttendeeNoShowUpdatedAuditDisplayData {
const { fields } = storedData;
return {
noShowAttendee: fields.noShowAttendee.new,
@@ -59,16 +59,18 @@ export class AttendeeRemovedAuditActionService
return { isMigrated: false, latestData: validated };
}
async getDisplayTitle(): Promise<TranslationWithParams> {
async getDisplayTitle(_: { storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }; userTimeZone: string }): Promise<TranslationWithParams> {
return { key: "booking_audit_action.attendee_removed" };
}
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): AttendeeRemovedAuditDisplayData {
getDisplayJson({
storedData,
}: {
storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> };
userTimeZone: string;
}): AttendeeRemovedAuditDisplayData {
const { fields } = storedData;
// Note: fields.attendees stores the state change (old -> new), not the removed attendees directly
// old = attendees before removal, new = remaining attendees after removal
const remainingAttendeesSet = new Set(fields.attendees.new ?? []);
// Compute removed attendees: those in old but not in new (remaining)
const removedAttendees = (fields.attendees.old ?? []).filter(
(email) => !remainingAttendeesSet.has(email)
);
@@ -1,4 +1,5 @@
import { z } from "zod";
import { formatInTimeZone } from "date-fns-tz";
/**
* Audit Action Service Helper
@@ -61,5 +62,25 @@ export class AuditActionServiceHelper<
const parsed = z.object({ version: z.number() }).parse(data);
return parsed.version;
}
/**
* Format date in user's timezone with format: MMM d, yyyy (e.g., "Jul 7, 2025")
* @param date - Date string or timestamp
* @param timeZone - User's timezone (defaults to UTC)
* @returns Formatted date string
*/
static formatDateInTimeZone(date: string | number, timeZone: string = "UTC"): string {
return formatInTimeZone(new Date(date), timeZone, "MMM d, yyyy");
}
/**
* Format datetime in user's timezone with format: yyyy-MM-dd HH:mm:ss (e.g., "2025-07-07 09:42:10")
* @param date - Date string or timestamp
* @param timeZone - User's timezone (defaults to UTC)
* @returns Formatted datetime string
*/
static formatDateTimeInTimeZone(date: string | number, timeZone: string = "UTC"): string {
return formatInTimeZone(new Date(date), timeZone, "yyyy-MM-dd HH:mm:ss");
}
}
@@ -61,11 +61,16 @@ export class CancelledAuditActionService
return { isMigrated: false, latestData: validated };
}
async getDisplayTitle(): Promise<TranslationWithParams> {
async getDisplayTitle(_: { storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }; userTimeZone: string }): Promise<TranslationWithParams> {
return { key: "booking_audit_action.cancelled" };
}
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): CancelledAuditDisplayData {
getDisplayJson({
storedData,
}: {
storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> };
userTimeZone: string;
}): CancelledAuditDisplayData {
const { fields } = storedData;
return {
cancellationReason: fields.cancellationReason.new ?? null,
@@ -61,15 +61,23 @@ export class CreatedAuditActionService implements IAuditActionService<
return { isMigrated: false, latestData: validated };
}
async getDisplayTitle(): Promise<TranslationWithParams> {
async getDisplayTitle(_: { storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }; userTimeZone: string }): Promise<TranslationWithParams> {
return { key: "booking_audit_action.created" };
}
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): CreatedAuditDisplayData {
getDisplayJson({
storedData,
userTimeZone,
}: {
storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> };
userTimeZone: string;
}): CreatedAuditDisplayData {
const { fields } = storedData;
const timeZone = userTimeZone;
return {
startTime: new Date(fields.startTime).toISOString(),
endTime: new Date(fields.endTime).toISOString(),
startTime: AuditActionServiceHelper.formatDateTimeInTimeZone(fields.startTime, timeZone),
endTime: AuditActionServiceHelper.formatDateTimeInTimeZone(fields.endTime, timeZone),
status: fields.status,
};
}
@@ -59,11 +59,16 @@ export class HostNoShowUpdatedAuditActionService
return { isMigrated: false, latestData: validated };
}
async getDisplayTitle(): Promise<TranslationWithParams> {
async getDisplayTitle(_: { storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }; userTimeZone: string }): Promise<TranslationWithParams> {
return { key: "booking_audit_action.host_no_show_updated" };
}
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): HostNoShowUpdatedAuditDisplayData {
getDisplayJson({
storedData,
}: {
storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> };
userTimeZone: string;
}): HostNoShowUpdatedAuditDisplayData {
const { fields } = storedData;
return {
noShowHost: fields.noShowHost.new,
@@ -64,19 +64,34 @@ export interface IAuditActionService<
/**
* Get flattened JSON data for display (fields only, no version wrapper)
* Optional - implement only if custom display formatting is needed
* @param storedData - Parsed stored data { version, fields }
* @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?(storedData: { version: number; fields: z.infer<TStoredFieldsSchema> }): unknown;
getDisplayJson?(params: { storedData: { version: number; fields: z.infer<TStoredFieldsSchema> }; userTimeZone: 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 storedData - Parsed stored data { version, fields }
* @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(storedData: { version: number; fields: z.infer<TStoredFieldsSchema> }): Promise<TranslationWithParams>;
getDisplayTitle(params: { storedData: { version: number; fields: z.infer<TStoredFieldsSchema> }; userTimeZone: string }): 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 Array of field objects with label and value translation keys
*/
getDisplayFields?(storedData: { version: number; fields: z.infer<TStoredFieldsSchema> }): Array<{
labelKey: string; // Translation key for field label
valueKey: string; // Translation key for field value
}>;
/**
* Migrate old version data to latest version
@@ -60,7 +60,7 @@ export class LocationChangedAuditActionService
return { isMigrated: false, latestData: validated };
}
async getDisplayTitle(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): Promise<TranslationWithParams> {
async getDisplayTitle({ storedData }: { storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }; userTimeZone?: string }): Promise<TranslationWithParams> {
const { fields } = storedData;
// TODO: Ideally we want to translate the location label to the user's locale
// We currently don't accept requesting user's translate fn here, fix it later.
@@ -15,6 +15,7 @@ const fieldsSchemaV1 = z.object({
assignedToId: NumberChangeSchema,
assignedById: NumberChangeSchema,
reassignmentReason: StringChangeSchema,
reassignmentType: z.enum(["manual", "roundRobin"]),
userPrimaryEmail: StringChangeSchema.optional(),
title: StringChangeSchema.optional(),
});
@@ -64,7 +65,7 @@ export class ReassignmentAuditActionService
return { isMigrated: false, latestData: validated };
}
async getDisplayTitle(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): Promise<TranslationWithParams> {
async getDisplayTitle({ storedData }: { storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }; userTimeZone: string }): Promise<TranslationWithParams> {
const { fields } = storedData;
const user = await this.userRepository.findById({ id: fields.assignedToId.new });
const reassignedToName = user?.name || "Unknown";
@@ -74,13 +75,34 @@ export class ReassignmentAuditActionService
};
}
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): ReassignmentAuditDisplayData {
getDisplayJson({
storedData,
}: {
storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> };
userTimeZone: string;
}): ReassignmentAuditDisplayData {
const { fields } = storedData;
return {
newAssignedToId: fields.assignedToId.new,
reassignmentReason: fields.reassignmentReason.new ?? null,
};
}
getDisplayFields(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): Array<{
labelKey: string;
valueKey: string;
}> {
const { fields } = storedData;
const typeTranslationKey = `booking_audit_action.assignmentType_${fields.reassignmentType}`;
return [
{
labelKey: "booking_audit_action.type",
valueKey: typeTranslationKey,
}
];
}
}
export type ReassignmentAuditData = z.infer<typeof fieldsSchemaV1>;
@@ -60,11 +60,16 @@ export class RejectedAuditActionService
return { isMigrated: false, latestData: validated };
}
async getDisplayTitle(): Promise<TranslationWithParams> {
async getDisplayTitle(_: { storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }; userTimeZone: string }): Promise<TranslationWithParams> {
return { key: "booking_audit_action.rejected" };
}
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): RejectedAuditDisplayData {
getDisplayJson({
storedData,
}: {
storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> };
userTimeZone: string;
}): RejectedAuditDisplayData {
const { fields } = storedData;
return {
rejectionReason: fields.rejectionReason.new ?? null,
@@ -61,11 +61,16 @@ export class RescheduleRequestedAuditActionService
return { isMigrated: false, latestData: validated };
}
async getDisplayTitle(): Promise<TranslationWithParams> {
async getDisplayTitle(_: { storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }; userTimeZone: string }): Promise<TranslationWithParams> {
return { key: "booking_audit_action.reschedule_requested" };
}
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): RescheduleRequestedAuditDisplayData {
getDisplayJson({
storedData,
}: {
storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> };
userTimeZone: string;
}): RescheduleRequestedAuditDisplayData {
const { fields } = storedData;
return {
reason: fields.cancellationReason.new ?? null,
@@ -61,21 +61,86 @@ export class RescheduledAuditActionService
return { isMigrated: false, latestData: validated };
}
async getDisplayTitle(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): Promise<TranslationWithParams> {
async getDisplayTitle({
storedData,
userTimeZone,
}: {
storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> };
userTimeZone: string;
}): Promise<TranslationWithParams> {
const rescheduledToUid = storedData.fields.rescheduledToUid.new;
const timeZone = userTimeZone;
// Format dates in user timezone
const oldDate = storedData.fields.startTime.old
? AuditActionServiceHelper.formatDateInTimeZone(storedData.fields.startTime.old, timeZone)
: "";
const newDate = storedData.fields.startTime.new
? AuditActionServiceHelper.formatDateInTimeZone(storedData.fields.startTime.new, timeZone)
: "";
return {
key: "booking_audit_action.rescheduled",
components: rescheduledToUid ? [{ type: "link", href: `/booking/${rescheduledToUid}` }] : undefined,
params: {
oldDate,
newDate,
},
components: rescheduledToUid ? [{ type: "link", href: `/booking/${rescheduledToUid}/logs` }] : undefined,
};
}
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): RescheduledAuditDisplayData {
const { fields } = storedData;
getDisplayTitleForRescheduledFromLog({
fromRescheduleUid,
userTimeZone,
parsedData,
}: {
fromRescheduleUid: string;
userTimeZone: string;
parsedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> };
}): TranslationWithParams {
const timeZone = userTimeZone;
// Format dates in user timezone
const oldDate = parsedData.fields.startTime.old
? AuditActionServiceHelper.formatDateInTimeZone(parsedData.fields.startTime.old, timeZone)
: "";
const newDate = parsedData.fields.startTime.new
? AuditActionServiceHelper.formatDateInTimeZone(parsedData.fields.startTime.new, timeZone)
: "";
return {
previousStartTime: fields.startTime.old ?? null,
newStartTime: fields.startTime.new ?? null,
previousEndTime: fields.endTime.old ?? null,
newEndTime: fields.endTime.new ?? null,
key: "booking_audit_action.rescheduled_from",
params: {
oldDate,
newDate,
},
components: [{ type: "link", href: `/booking/${fromRescheduleUid}/logs` }],
};
}
getDisplayJson({
storedData,
userTimeZone,
}: {
storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> };
userTimeZone: string;
}): RescheduledAuditDisplayData {
const { fields } = storedData;
const timeZone = userTimeZone;
return {
previousStartTime: fields.startTime.old
? AuditActionServiceHelper.formatDateTimeInTimeZone(fields.startTime.old, timeZone)
: null,
newStartTime: fields.startTime.new
? AuditActionServiceHelper.formatDateTimeInTimeZone(fields.startTime.new, timeZone)
: null,
previousEndTime: fields.endTime.old
? AuditActionServiceHelper.formatDateTimeInTimeZone(fields.endTime.old, timeZone)
: null,
newEndTime: fields.endTime.new
? AuditActionServiceHelper.formatDateTimeInTimeZone(fields.endTime.new, timeZone)
: null,
rescheduledToUid: fields.rescheduledToUid.new ?? null,
};
}
@@ -1,5 +1,6 @@
import { z } from "zod";
import { BookingStatus } from "@calcom/prisma/enums";
/**
* Common change schemas for audit data
* These represent old -> new value transitions
@@ -25,3 +26,8 @@ export const NumberChangeSchema = z.object({
new: z.number(),
});
export const BookingStatusChangeSchema = z.object({
old: z.nativeEnum(BookingStatus).nullable(),
new: z.nativeEnum(BookingStatus),
});
@@ -55,4 +55,11 @@ export interface IBookingAuditRepository {
* @returns Array of audit logs with actor information, ordered by timestamp DESC
*/
findAllForBooking(bookingUid: string): Promise<BookingAuditWithActor[]>;
/**
* Retrieves all RESCHEDULED audit logs for a specific booking
* @param bookingUid - The unique identifier of the booking
* @returns Array of RESCHEDULED audit logs with actor information, ordered by timestamp DESC
*/
findRescheduledLogsOfBooking(bookingUid: string): Promise<BookingAuditWithActor[]>;
}
@@ -50,5 +50,22 @@ export class PrismaBookingAuditRepository implements IBookingAuditRepository {
},
});
}
async findRescheduledLogsOfBooking(bookingUid: string): Promise<BookingAuditWithActor[]> {
return this.deps.prismaClient.bookingAudit.findMany({
where: {
bookingUid,
action: "RESCHEDULED",
},
include: {
actor: {
select: safeActorSelect,
},
},
orderBy: {
timestamp: "desc",
},
});
}
}
@@ -0,0 +1,87 @@
import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import type { IAuditActionService } from "../actions/IAuditActionService";
import type { BookingAuditAction } from "../repository/IBookingAuditRepository";
// Import all action services
import { CreatedAuditActionService, type CreatedAuditData } from "../actions/CreatedAuditActionService";
import { CancelledAuditActionService, type CancelledAuditData } from "../actions/CancelledAuditActionService";
import { RescheduledAuditActionService, type RescheduledAuditData } from "../actions/RescheduledAuditActionService";
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 { 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";
/**
* Union type for all audit action data types
* Used for type-safe handling of action-specific data
*/
export type AuditActionData =
| CreatedAuditData
| CancelledAuditData
| RescheduledAuditData
| AcceptedAuditData
| RescheduleRequestedAuditData
| AttendeeAddedAuditData
| HostNoShowUpdatedAuditData
| RejectedAuditData
| AttendeeRemovedAuditData
| ReassignmentAuditData
| LocationChangedAuditData
| AttendeeNoShowUpdatedAuditData;
/**
* BookingAuditActionServiceRegistry
*
* Centralized registry for all booking audit action services.
* Provides a single source of truth for action service mapping and eliminates
* code duplication between consumer and viewer services.
*/
interface BookingAuditActionServiceRegistryDeps {
userRepository: UserRepository;
}
export class BookingAuditActionServiceRegistry {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private readonly actionServices: Map<BookingAuditAction, IAuditActionService<any, any>>;
constructor(private deps: BookingAuditActionServiceRegistryDeps) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const services: Array<[BookingAuditAction, IAuditActionService<any, any>]> = [
["CREATED", new CreatedAuditActionService()],
["CANCELLED", new CancelledAuditActionService()],
["RESCHEDULED", new RescheduledAuditActionService()],
["ACCEPTED", new AcceptedAuditActionService()],
["RESCHEDULE_REQUESTED", new RescheduleRequestedAuditActionService()],
["ATTENDEE_ADDED", new AttendeeAddedAuditActionService()],
["HOST_NO_SHOW_UPDATED", new HostNoShowUpdatedAuditActionService()],
["REJECTED", new RejectedAuditActionService()],
["ATTENDEE_REMOVED", new AttendeeRemovedAuditActionService()],
["REASSIGNMENT", new ReassignmentAuditActionService(deps.userRepository)],
["LOCATION_CHANGED", new LocationChangedAuditActionService()],
["ATTENDEE_NO_SHOW_UPDATED", new AttendeeNoShowUpdatedAuditActionService()],
];
this.actionServices = new Map(services);
}
/**
* Get Action Service - Returns the appropriate action service for the given action type
*
* @param action - The booking audit action type
* @returns The corresponding action service instance
* @throws Error if no service is found for the action
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getActionService(action: BookingAuditAction): IAuditActionService<any, any> {
const service = this.actionServices.get(action);
if (!service) {
throw new Error(`No action service found for: ${action}`);
}
return service;
}
}
@@ -1,5 +1,19 @@
import { z } from "zod";
import type { Actor } from "../../../bookings/lib/types/actor";
import type { BookingAuditTaskProducerActionData } from "../types/bookingAuditTask";
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 { ReassignmentAuditActionService } from "../actions/ReassignmentAuditActionService";
import { RejectedAuditActionService } from "../actions/RejectedAuditActionService";
import { RescheduleRequestedAuditActionService } from "../actions/RescheduleRequestedAuditActionService";
import { RescheduledAuditActionService } from "../actions/RescheduledAuditActionService";
/**
* BookingAuditProducerService Interface
@@ -7,28 +21,103 @@ import type { BookingAuditTaskProducerActionData } from "../types/bookingAuditTa
* Producer abstraction for queueing booking audit tasks.
* Allows for multiple implementations (e.g., Tasker, Trigger.dev).
*
* Uses specialized methods per action type for better TypeScript performance
* and type safety, avoiding large discriminated unions.
*
* Implementations:
* - BookingAuditTaskerProducerService: Uses Tasker for local/background jobs
* - BookingAuditTriggerProducerService: (Future) Uses Trigger.dev for cloud jobs
*/
export interface BookingAuditProducerService {
/**
* Queue Audit - Push audit task for async processing
* Queue Audit - Legacy method for backwards compatibility with existing code
*
* Single method that handles all audit types using discriminated union.
* TypeScript ensures type safety between action and data.
*
* @param bookingUid - The booking UID
* @param actor - The actor performing the action
* @param organizationId - The organization ID (null for non-org bookings)
* @param actionData - Discriminated union of action type and its data
*
* @example
* await bookingAuditProducerService.queueAudit(bookingUid, actor, organizationId, {
* action: "CREATED",
* data: { startTime, endTime, status }
* });
* @deprecated Use specialized methods (queueCreatedAudit, queueCancelledAudit, etc.) instead
*/
queueAudit(bookingUid: string, actor: Actor, organizationId: number | null, actionData: BookingAuditTaskProducerActionData): Promise<void>;
queueCreatedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
data: z.infer<typeof CreatedAuditActionService.latestFieldsSchema>;
}): Promise<void>;
queueRescheduledAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
data: z.infer<typeof RescheduledAuditActionService.latestFieldsSchema>;
}): Promise<void>;
queueAcceptedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
data: z.infer<typeof AcceptedAuditActionService.latestFieldsSchema>;
}): Promise<void>;
queueCancelledAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
data: z.infer<typeof CancelledAuditActionService.latestFieldsSchema>;
}): Promise<void>;
queueRescheduleRequestedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
data: z.infer<typeof RescheduleRequestedAuditActionService.latestFieldsSchema>;
}): Promise<void>;
queueAttendeeAddedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
data: z.infer<typeof AttendeeAddedAuditActionService.latestFieldsSchema>;
}): Promise<void>;
queueHostNoShowUpdatedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
data: z.infer<typeof HostNoShowUpdatedAuditActionService.latestFieldsSchema>;
}): Promise<void>;
queueRejectedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
data: z.infer<typeof RejectedAuditActionService.latestFieldsSchema>;
}): Promise<void>;
queueAttendeeRemovedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
data: z.infer<typeof AttendeeRemovedAuditActionService.latestFieldsSchema>;
}): Promise<void>;
queueReassignmentAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
data: z.infer<typeof ReassignmentAuditActionService.latestFieldsSchema>;
}): Promise<void>;
queueLocationChangedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
data: z.infer<typeof LocationChangedAuditActionService.latestFieldsSchema>;
}): Promise<void>;
queueAttendeeNoShowUpdatedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
data: z.infer<typeof AttendeeNoShowUpdatedAuditActionService.latestFieldsSchema>;
}): Promise<void>;
}
@@ -2,11 +2,12 @@ import type { JsonValue } from "@calcom/types/Json";
import logger from "@calcom/lib/logger";
import type { IFeaturesRepository } from "@calcom/features/flags/features.repository.interface";
import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import type { Actor } from "../../../bookings/lib/types/actor";
import type { BookingAuditTaskConsumerPayload } from "../types/bookingAuditTask";
import { BookingAuditTaskConsumerPayloadSchema } from "../types/bookingAuditTask";
import { CreatedAuditActionService, type CreatedAuditData } from "../actions/CreatedAuditActionService";
import type { BookingAuditTaskBasePayload } from "../types/bookingAuditTask";
import { BookingAuditTaskBaseSchema } from "../types/bookingAuditTask";
import { BookingAuditActionServiceRegistry, type AuditActionData } from "./BookingAuditActionServiceRegistry";
import type { IBookingAuditRepository, BookingAuditType, BookingAuditAction } from "../repository/IBookingAuditRepository";
import type { IAuditActorRepository } from "../repository/IAuditActorRepository";
import { safeStringify } from "@calcom/lib/safeStringify";
@@ -15,6 +16,7 @@ interface BookingAuditTaskConsumerDeps {
bookingAuditRepository: IBookingAuditRepository;
auditActorRepository: IAuditActorRepository;
featuresRepository: IFeaturesRepository;
userRepository: UserRepository;
}
type CreateBookingAuditInput = {
@@ -45,44 +47,53 @@ type BookingAudit = {
*
* Note: PENDING and AWAITING_HOST actions are intentionally not implemented.
* These represent initial booking states captured by the CREATED action.
*
* Dependency Injection Note:
* - `userRepository` is included in this service's dependencies because it's required by
* ActionServices (e.g., ReassignmentAuditActionService) that need to fetch user data.
* - Currently, dependencies are passed down the flow: TaskConsumer → Registry → ActionService
* - Future improvement: Consider creating a container/module system for action services
* that can build instances directly with their dependencies, eliminating the need to
* pass dependencies through intermediate layers.
*/
export class BookingAuditTaskConsumer {
private readonly createdActionService: CreatedAuditActionService;
private readonly actionServiceRegistry: BookingAuditActionServiceRegistry;
private readonly bookingAuditRepository: IBookingAuditRepository;
private readonly auditActorRepository: IAuditActorRepository;
private readonly featuresRepository: IFeaturesRepository;
private readonly userRepository: UserRepository;
constructor(private readonly deps: BookingAuditTaskConsumerDeps) {
this.bookingAuditRepository = deps.bookingAuditRepository;
this.auditActorRepository = deps.auditActorRepository;
this.featuresRepository = deps.featuresRepository;
this.userRepository = deps.userRepository;
// Each service instantiates its own helper with its specific schema
this.createdActionService = new CreatedAuditActionService();
// Centralized registry for all action services
this.actionServiceRegistry = new BookingAuditActionServiceRegistry({ userRepository: this.userRepository });
}
/**
* Process Audit Task - Entry point for task handler
*
* This method handles:
* 1. Schema validation (accepts all supported versions)
* 1. Base schema validation (parses data as unknown)
* 2. Feature flag checks
* 3. Schema migration to latest version if needed
* 3. Action-specific data validation and migration
* 4. Routing to appropriate action handlers
*
* Schema Migration:
* - Validates payload against all supported schema versions
* - Migrates old versions to latest at task boundary
* - Updates task payload in DB if migration occurs
* - Ensures retries always use latest schema version
* Two-Stage Parsing:
* - First validates base fields with lean schema (data as unknown)
* - Then validates data with action-specific schema via action service
* - This avoids large discriminated unions for better TypeScript performance
*
* @param payload - The booking audit task payload (unknown type, will be validated)
* @param taskId - Optional task ID for updating migrated payload in DB
* @returns Promise that resolves when processing is complete
*/
async processAuditTask(payload: unknown, taskId: string): Promise<void> {
// Validate payload schema (accepts all supported versions)
const parseResult = BookingAuditTaskConsumerPayloadSchema.safeParse(payload);
// Step 1: Parse with lean base schema (data as unknown)
const parseResult = BookingAuditTaskBaseSchema.safeParse(payload);
if (!parseResult.success) {
const errorMsg = `Invalid booking audit payload: ${safeStringify(parseResult.error.errors)}`;
@@ -110,18 +121,18 @@ export class BookingAuditTaskConsumer {
return;
}
// Step 2: Validate and migrate data with action-specific schema
const dataInLatestFormat = await this.migrateIfNeeded({ action, data, payload: validatedPayload, taskId });
if (action !== "CREATED") {
throw new Error(`Unsupported audit action: ${action}`);
}
await this.onBookingAction({ bookingUid, actor, action, data: dataInLatestFormat, timestamp });
// dataInLatestFormat is validated by action-specific schema in migrateIfNeeded
await this.onBookingAction({ bookingUid, actor, action, data: dataInLatestFormat as AuditActionData, timestamp });
}
/**
* Migrate If Needed - Migrates data to latest version
* Migrate If Needed - Validates and migrates data to latest version
*
* Calls the action service's migrateToLatest method which:
* This is where action-specific data validation happens.
* The action service's migrateToLatest method:
* - Validates the data against all supported versions
* - Migrates to latest version if needed
* - Returns validated data with migration status
@@ -129,15 +140,15 @@ export class BookingAuditTaskConsumer {
* If migration occurred, updates the task payload in DB for retries.
*/
private async migrateIfNeeded(params: {
action: BookingAuditTaskConsumerPayload["action"];
action: BookingAuditAction;
data: unknown;
payload: BookingAuditTaskConsumerPayload;
payload: BookingAuditTaskBasePayload;
taskId: string;
}) {
const { action, data, payload, taskId } = params;
const actionService = this.getActionService(action);
const actionService = this.actionServiceRegistry.getActionService(action);
// migrateToLatest is now required - validates and migrates if needed
// migrateToLatest validates data with action-specific schema and migrates if needed
const migrationResult = actionService.migrateToLatest(data);
// If migrated, update task payload in DB
@@ -151,20 +162,6 @@ export class BookingAuditTaskConsumer {
return migrationResult.latestData;
}
/**
* Get Action Service - Returns the appropriate action service for the given action type
*
* @param action - The booking audit action type
* @returns The corresponding action service instance
*/
private getActionService(action: BookingAuditAction) {
if (action !== "CREATED") {
throw new Error(`Unsupported audit action: ${action}`);
}
return this.createdActionService;
}
/**
* Update Task Payload - Updates the task payload in DB with migrated data
*
@@ -176,7 +173,7 @@ export class BookingAuditTaskConsumer {
* @param taskId - Task ID (required for DB update)
*/
private async updateTaskPayload(
payload: BookingAuditTaskConsumerPayload,
payload: BookingAuditTaskBasePayload,
latestData: unknown,
taskId: string
): Promise<void> {
@@ -256,7 +253,7 @@ export class BookingAuditTaskConsumer {
*
* Maps booking actions to their corresponding audit record types:
* - CREATED → RECORD_CREATED
* - Future actions like CANCELLED, RESCHEDULED → RECORD_UPDATED
* - All other actions (CANCELLED, RESCHEDULED, etc.) → RECORD_UPDATED
* - Future actions like DELETED → RECORD_DELETED
*
* @param action - The booking audit action
@@ -265,12 +262,29 @@ export class BookingAuditTaskConsumer {
private getRecordType(params: { action: BookingAuditAction }): BookingAuditType {
const { action } = params;
switch (action) {
case "CREATED":
return "RECORD_CREATED";
// Future actions will map to RECORD_UPDATED or RECORD_DELETED
default:
throw new Error(`Unsupported action for record type mapping: ${action}`);
// All update actions fall through to return RECORD_UPDATED
case "CANCELLED":
case "ACCEPTED":
case "REJECTED":
case "RESCHEDULED":
case "RESCHEDULE_REQUESTED":
case "ATTENDEE_ADDED":
case "ATTENDEE_REMOVED":
case "REASSIGNMENT":
case "LOCATION_CHANGED":
case "HOST_NO_SHOW_UPDATED":
case "ATTENDEE_NO_SHOW_UPDATED":
return "RECORD_UPDATED";
// PENDING and AWAITING_HOST are not implemented as they represent initial states
case "PENDING":
case "AWAITING_HOST":
throw new Error(`Action ${action} is not supported - it represents an initial booking state captured by CREATED`);
}
}
@@ -278,11 +292,11 @@ export class BookingAuditTaskConsumer {
bookingUid: string;
actor: Actor;
action: BookingAuditAction;
data: CreatedAuditData;
data: AuditActionData;
timestamp: number;
}): Promise<BookingAudit> {
const { bookingUid, actor, action, data, timestamp } = params;
const actionService = this.getActionService(action);
const actionService = this.actionServiceRegistry.getActionService(action);
const versionedData = actionService.getVersionedData(data);
const actorId = await this.resolveActorId(actor);
const recordType = this.getRecordType({ action });
@@ -292,7 +306,8 @@ export class BookingAuditTaskConsumer {
actorId,
type: recordType,
action,
data: versionedData,
// versionedData is { version: number; fields: unknown } which is JsonValue-compatible
data: versionedData as JsonValue,
timestamp: new Date(timestamp),
});
}
@@ -1,11 +1,28 @@
import { z } from "zod";
import type { Tasker } from "@calcom/features/tasker/tasker";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { safeStringify } from "@calcom/lib/safeStringify";
import type { ISimpleLogger } from "@calcom/features/di/shared/services/logger.service";
import type { Actor } from "../../../bookings/lib/types/actor";
import type { BookingAuditTaskProducerActionData } from "../types/bookingAuditTask";
import type { BookingAuditAction, BookingAuditTaskProducerActionData } from "../types/bookingAuditTask";
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 { ReassignmentAuditActionService } from "../actions/ReassignmentAuditActionService";
import { RejectedAuditActionService } from "../actions/RejectedAuditActionService";
import { RescheduleRequestedAuditActionService } from "../actions/RescheduleRequestedAuditActionService";
import { RescheduledAuditActionService } from "../actions/RescheduledAuditActionService";
import type { BookingAuditProducerService } from "./BookingAuditProducerService.interface";
interface BookingAuditTaskerProducerServiceDeps {
tasker: Tasker;
log: ISimpleLogger;
}
/**
@@ -19,41 +36,207 @@ interface BookingAuditTaskerProducerServiceDeps {
*/
export class BookingAuditTaskerProducerService implements BookingAuditProducerService {
private readonly tasker: Tasker;
private readonly log: BookingAuditTaskerProducerServiceDeps["log"];
constructor(private readonly deps: BookingAuditTaskerProducerServiceDeps) {
this.tasker = deps.tasker;
this.log = deps.log;
}
/**
* Queue Audit - Push audit task to Tasker for async processing
* Queue Audit - Legacy method for backwards compatibility
*
* Single method that handles all audit types using discriminated union.
* TypeScript ensures type safety between action and data.
* Timestamp is automatically captured when the task is queued.
*
* @param bookingUid - The booking UID
* @param actor - The actor performing the action
* @param organizationId - The organization ID (null for non-org bookings)
* @param actionData - Discriminated union of action type and its data
*
* @example
* await bookingAuditProducerService.queueAudit(bookingUid, actor, organizationId, {
* action: "CREATED",
* data: { startTime, endTime, status }
* });
* @deprecated Use specialized methods (queueCreatedAudit, queueCancelledAudit, etc.) instead
*/
async queueAudit(bookingUid: string, actor: Actor, organizationId: number | null, actionData: BookingAuditTaskProducerActionData): Promise<void> {
// Skip queueing for non-organization bookings
if (organizationId === null) {
return;
}
await this.tasker.create("bookingAudit", {
async queueAudit(
bookingUid: string,
actor: Actor,
organizationId: number | null,
actionData: BookingAuditTaskProducerActionData
): Promise<void> {
await this.queueTask({
bookingUid,
actor,
organizationId,
timestamp: Date.now(),
...actionData,
action: actionData.action,
data: actionData.data,
});
}
/**
* Internal helper to queue audit task to Tasker
* @param params.action - Must be a valid BookingAuditAction value (TYPE from action services are string-typed)
*/
private async queueTask(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
action: string;
data: unknown;
}): Promise<void> {
// Skip queueing for non-organization bookings
if (params.organizationId === null) {
return;
}
if (IS_PRODUCTION) {
return;
}
try {
// Cast action to BookingAuditAction since action service TYPE constants are typed as string
await this.tasker.create("bookingAudit", {
bookingUid: params.bookingUid,
actor: params.actor,
organizationId: params.organizationId,
timestamp: Date.now(),
action: params.action as BookingAuditAction,
data: params.data,
});
} catch (error) {
this.log.error(`Error while queueing ${params.action} audit`, safeStringify(error));
}
}
async queueCreatedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
data: z.infer<typeof CreatedAuditActionService.latestFieldsSchema>;
}): Promise<void> {
await this.queueTask({
...params,
action: CreatedAuditActionService.TYPE,
});
}
async queueRescheduledAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
data: z.infer<typeof RescheduledAuditActionService.latestFieldsSchema>;
}): Promise<void> {
await this.queueTask({
...params,
action: RescheduledAuditActionService.TYPE,
});
}
async queueAcceptedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
data: z.infer<typeof AcceptedAuditActionService.latestFieldsSchema>;
}): Promise<void> {
await this.queueTask({
...params,
action: AcceptedAuditActionService.TYPE,
});
}
async queueCancelledAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
data: z.infer<typeof CancelledAuditActionService.latestFieldsSchema>;
}): Promise<void> {
await this.queueTask({
...params,
action: CancelledAuditActionService.TYPE,
});
}
async queueRescheduleRequestedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
data: z.infer<typeof RescheduleRequestedAuditActionService.latestFieldsSchema>;
}): Promise<void> {
await this.queueTask({
...params,
action: RescheduleRequestedAuditActionService.TYPE,
});
}
async queueAttendeeAddedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
data: z.infer<typeof AttendeeAddedAuditActionService.latestFieldsSchema>;
}): Promise<void> {
await this.queueTask({
...params,
action: AttendeeAddedAuditActionService.TYPE,
});
}
async queueHostNoShowUpdatedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
data: z.infer<typeof HostNoShowUpdatedAuditActionService.latestFieldsSchema>;
}): Promise<void> {
await this.queueTask({
...params,
action: HostNoShowUpdatedAuditActionService.TYPE,
});
}
async queueRejectedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
data: z.infer<typeof RejectedAuditActionService.latestFieldsSchema>;
}): Promise<void> {
await this.queueTask({
...params,
action: RejectedAuditActionService.TYPE,
});
}
async queueAttendeeRemovedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
data: z.infer<typeof AttendeeRemovedAuditActionService.latestFieldsSchema>;
}): Promise<void> {
await this.queueTask({
...params,
action: AttendeeRemovedAuditActionService.TYPE,
});
}
async queueReassignmentAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
data: z.infer<typeof ReassignmentAuditActionService.latestFieldsSchema>;
}): Promise<void> {
await this.queueTask({
...params,
action: ReassignmentAuditActionService.TYPE,
});
}
async queueLocationChangedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
data: z.infer<typeof LocationChangedAuditActionService.latestFieldsSchema>;
}): Promise<void> {
await this.queueTask({
...params,
action: LocationChangedAuditActionService.TYPE,
});
}
async queueAttendeeNoShowUpdatedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
data: z.infer<typeof AttendeeNoShowUpdatedAuditActionService.latestFieldsSchema>;
}): Promise<void> {
await this.queueTask({
...params,
action: AttendeeNoShowUpdatedAuditActionService.TYPE,
});
}
}
@@ -1,14 +1,19 @@
import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import type { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import type { ISimpleLogger } from "@calcom/features/di/shared/services/logger.service";
import { CreatedAuditActionService, type CreatedAuditDisplayData } from "../actions/CreatedAuditActionService";
import { BookingAuditActionServiceRegistry } from "./BookingAuditActionServiceRegistry";
import type { IBookingAuditRepository, BookingAuditWithActor, BookingAuditAction, BookingAuditType } from "../repository/IBookingAuditRepository";
import type { AuditActorType } from "../repository/IAuditActorRepository";
import type { TranslationWithParams } from "../actions/IAuditActionService";
import { RescheduledAuditActionService } from "../actions/RescheduledAuditActionService";
import { IS_PRODUCTION } from "@calcom/lib/constants";
type AuditDisplayData = CreatedAuditDisplayData;
interface BookingAuditViewerServiceDeps {
bookingAuditRepository: IBookingAuditRepository;
userRepository: UserRepository;
bookingRepository: BookingRepository;
log: ISimpleLogger;
}
type EnrichedAuditLog = {
@@ -18,10 +23,12 @@ type EnrichedAuditLog = {
action: BookingAuditAction;
timestamp: string;
createdAt: string;
data: AuditDisplayData;
data: Record<string, unknown> | null;
actionDisplayTitle: TranslationWithParams;
displayFields?: Array<{ labelKey: string; valueKey: string }>;
actor: {
id: string;
type: string;
type: AuditActorType;
userUuid: string | null;
attendeeId: number | null;
name: string | null;
@@ -36,64 +43,168 @@ type EnrichedAuditLog = {
* BookingAuditViewerService - Service for viewing and formatting booking audit logs
*/
export class BookingAuditViewerService {
private readonly createdActionService: CreatedAuditActionService;
private readonly actionServiceRegistry: BookingAuditActionServiceRegistry;
private readonly bookingAuditRepository: IBookingAuditRepository;
private readonly userRepository: UserRepository;
private readonly bookingRepository: BookingRepository;
private readonly rescheduledAuditActionService: RescheduledAuditActionService;
private readonly log: BookingAuditViewerServiceDeps["log"];
constructor(private readonly deps: BookingAuditViewerServiceDeps) {
this.bookingAuditRepository = deps.bookingAuditRepository;
this.userRepository = deps.userRepository;
this.bookingRepository = deps.bookingRepository;
this.log = deps.log;
this.rescheduledAuditActionService = new RescheduledAuditActionService();
this.createdActionService = new CreatedAuditActionService();
this.actionServiceRegistry = new BookingAuditActionServiceRegistry({ userRepository: this.userRepository });
}
/**
* Get audit logs for a booking with full enrichment and formatting
* Handles permission checks, fetches logs, enriches actors, and formats display
*
* For bookings created from a reschedule (has fromReschedule field), this also
* fetches the last RESCHEDULED log from the previous booking and includes it
* as the first log entry with "rescheduled from" context.
*/
async getAuditLogsForBooking(
bookingUid: string,
_userId: number,
_userEmail: string
_userEmail: string,
userTimeZone?: string
): Promise<{ bookingUid: string; auditLogs: EnrichedAuditLog[] }> {
// Check permissions
await this.checkPermissions();
// Fetch audit logs
const auditLogs = await this.bookingAuditRepository.findAllForBooking(bookingUid);
// Enrich and format audit logs
// Default to UTC if no timezone provided (backwards compatibility)
const timezone = userTimeZone ?? "UTC";
const enrichedAuditLogs = await Promise.all(
auditLogs.map(async (log) => {
const enrichedActor = await this.enrichActorInformation(log.actor);
const actionService = this.getActionService(log.action);
const parsedData = actionService.parseStored(log.data);
return {
id: log.id,
bookingUid: log.bookingUid,
type: log.type,
action: log.action,
timestamp: log.timestamp.toISOString(),
createdAt: log.createdAt.toISOString(),
data: actionService.getDisplayJson(parsedData),
actor: {
...log.actor,
displayName: enrichedActor.displayName,
displayEmail: enrichedActor.displayEmail,
displayAvatar: enrichedActor.displayAvatar,
},
};
})
auditLogs.map((log) => this.enrichAuditLog(log, timezone))
);
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: timezone,
});
if (rescheduledFromLog) {
// Add the rescheduled log from the previous booking as the first entry
// (appears last chronologically since logs are ordered by timestamp DESC)
enrichedAuditLogs.unshift(rescheduledFromLog);
}
}
return {
bookingUid,
auditLogs: enrichedAuditLogs,
};
}
/**
* Enriches a single audit log with actor information and formatted display data
*/
private async enrichAuditLog(log: BookingAuditWithActor, userTimeZone: string): Promise<EnrichedAuditLog> {
const enrichedActor = await this.enrichActorInformation(log.actor);
const actionService = this.actionServiceRegistry.getActionService(log.action);
const parsedData = actionService.parseStored(log.data);
const actionDisplayTitle = await actionService.getDisplayTitle({ storedData: parsedData, userTimeZone });
// Get display data - use custom getDisplayJson if available, otherwise use raw fields
const displayData = (actionService.getDisplayJson
? actionService.getDisplayJson({ storedData: parsedData, userTimeZone })
: parsedData.fields) as Record<string, unknown> | null;
// Get display fields - use custom getDisplayFields if available
const displayFields = actionService.getDisplayFields
? actionService.getDisplayFields(parsedData)
: undefined;
return {
id: log.id,
bookingUid: log.bookingUid,
type: log.type,
action: log.action,
timestamp: log.timestamp.toISOString(),
createdAt: log.createdAt.toISOString(),
data: displayData,
actionDisplayTitle,
displayFields,
actor: {
id: log.actor.id,
type: log.actor.type,
userUuid: log.actor.userUuid,
attendeeId: log.actor.attendeeId,
name: log.actor.name,
createdAt: log.actor.createdAt,
displayName: enrichedActor.displayName,
displayEmail: enrichedActor.displayEmail,
displayAvatar: enrichedActor.displayAvatar,
},
};
}
/**
* Builds a "rescheduled from" log entry for bookings created from a reschedule.
* Fetches the RESCHEDULED log from the previous booking and transforms it
* to show "rescheduled from" context for the current booking.
*/
private async buildRescheduledFromLog({
fromRescheduleUid,
currentBookingUid,
userTimeZone,
}: {
fromRescheduleUid: string;
currentBookingUid: string;
userTimeZone: string;
}): Promise<EnrichedAuditLog | null> {
const rescheduledLogs = await this.bookingAuditRepository.findRescheduledLogsOfBooking(
fromRescheduleUid
);
// Find the specific log that created this booking by matching rescheduledToUid
const rescheduledLog = this.rescheduledAuditActionService.getMatchingLog({
rescheduledLogs,
rescheduledToBookingUid: currentBookingUid,
});
if (!rescheduledLog) {
this.log.error(`No rescheduled log found for booking ${fromRescheduleUid} -> ${currentBookingUid}`);
// Instead of crashing, we ignore because it is important to be able to access other logs as well.
return null;
}
const enrichedLog = await this.enrichAuditLog(rescheduledLog, userTimeZone);
const parsedData = this.rescheduledAuditActionService.parseStored(rescheduledLog.data);
// Transform the display data to show "rescheduled from" instead of "rescheduled to"
// by replacing rescheduledToUid with rescheduledFromUid
const transformedData = {
...enrichedLog.data,
rescheduledFromUid: fromRescheduleUid,
};
return {
...enrichedLog,
// Override bookingUid to associate with the current booking being viewed
bookingUid: currentBookingUid,
data: transformedData,
// Use a different translation key to show "Rescheduled from" instead of "Rescheduled"
actionDisplayTitle: this.rescheduledAuditActionService.getDisplayTitleForRescheduledFromLog({
fromRescheduleUid,
userTimeZone,
parsedData,
}),
};
}
/**
* Check if user has permission to view audit logs for a booking
*/
@@ -104,19 +215,6 @@ export class BookingAuditViewerService {
}
}
/**
* Get Action Service - Returns the appropriate action service for the given action type
*
* @param action - The booking audit action type
* @returns The corresponding action service instance
*/
private getActionService(action: BookingAuditAction) {
if (action !== "CREATED") {
throw new Error(`Unsupported audit action: ${action}`);
}
return this.createdActionService;
}
/**
* Enrich actor information with user details if userUuid exists
*/
@@ -176,4 +274,3 @@ export class BookingAuditViewerService {
throw new Error(`Unknown actor type: ${actor.type}`);
}
}
@@ -1,4 +1,5 @@
import { describe, expect, it, beforeEach, afterEach } from "vitest";
import { formatInTimeZone } from "date-fns-tz";
import { prisma } from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
@@ -184,10 +185,13 @@ describe("Booking Audit Integration", () => {
expect(auditLog.type).toBe("RECORD_CREATED");
// Verify audit data matches (getDisplayJson returns fields only, not versioned wrapper)
// getDisplayJson formats dates using formatDateTimeInTimeZone (yyyy-MM-dd HH:mm:ss format)
const displayData = auditLog.data
expect(displayData.startTime).toBe(booking!.startTime.toISOString());
expect(displayData.endTime).toBe(booking!.endTime.toISOString());
expect(displayData.status).toBe(booking!.status);
const expectedStartTime = formatInTimeZone(booking!.startTime, "UTC", "yyyy-MM-dd HH:mm:ss");
const expectedEndTime = formatInTimeZone(booking!.endTime, "UTC", "yyyy-MM-dd HH:mm:ss");
expect(displayData?.startTime).toBe(expectedStartTime);
expect(displayData?.endTime).toBe(expectedEndTime);
expect(displayData?.status).toBe(booking!.status);
});
it("should enrich actor information with user details", async () => {
@@ -1,29 +1,77 @@
import { z } from "zod";
import { ActorSchema } from "@calcom/features/bookings/lib/types/actor";
import {
CreatedAuditActionService,
} from "../actions/CreatedAuditActionService";
import { CreatedAuditActionService } from "../actions/CreatedAuditActionService";
import { RescheduledAuditActionService } from "../actions/RescheduledAuditActionService";
import { AcceptedAuditActionService } from "../actions/AcceptedAuditActionService";
import { CancelledAuditActionService } from "../actions/CancelledAuditActionService";
import { RescheduleRequestedAuditActionService } from "../actions/RescheduleRequestedAuditActionService";
import { AttendeeAddedAuditActionService } from "../actions/AttendeeAddedAuditActionService";
import { HostNoShowUpdatedAuditActionService } from "../actions/HostNoShowUpdatedAuditActionService";
import { RejectedAuditActionService } from "../actions/RejectedAuditActionService";
import { AttendeeRemovedAuditActionService } from "../actions/AttendeeRemovedAuditActionService";
import { ReassignmentAuditActionService } from "../actions/ReassignmentAuditActionService";
import { LocationChangedAuditActionService } from "../actions/LocationChangedAuditActionService";
import { AttendeeNoShowUpdatedAuditActionService } from "../actions/AttendeeNoShowUpdatedAuditActionService";
/**
* Supported booking audit actions
* Used for runtime validation of action field
*/
export const BookingAuditActionSchema = z.enum([
"CREATED",
"RESCHEDULED",
"ACCEPTED",
"CANCELLED",
"RESCHEDULE_REQUESTED",
"ATTENDEE_ADDED",
"HOST_NO_SHOW_UPDATED",
"REJECTED",
"ATTENDEE_REMOVED",
"REASSIGNMENT",
"LOCATION_CHANGED",
"ATTENDEE_NO_SHOW_UPDATED",
]);
const baseSchema = z.object({
export type BookingAuditAction = z.infer<typeof BookingAuditActionSchema>;
type AuditActionData<T extends { TYPE: string; latestFieldsSchema: z.ZodType }> = {
action: T["TYPE"];
data: z.infer<T["latestFieldsSchema"]>;
};
/**
* Producer action data type - discriminated union for type-safe queueing
* Used by the legacy queueAudit method for backwards compatibility
*/
export type BookingAuditTaskProducerActionData =
| AuditActionData<typeof CreatedAuditActionService>
| AuditActionData<typeof RescheduledAuditActionService>
| AuditActionData<typeof AcceptedAuditActionService>
| AuditActionData<typeof CancelledAuditActionService>
| AuditActionData<typeof RescheduleRequestedAuditActionService>
| AuditActionData<typeof AttendeeAddedAuditActionService>
| AuditActionData<typeof HostNoShowUpdatedAuditActionService>
| AuditActionData<typeof RejectedAuditActionService>
| AuditActionData<typeof AttendeeRemovedAuditActionService>
| AuditActionData<typeof ReassignmentAuditActionService>
| AuditActionData<typeof LocationChangedAuditActionService>
| AuditActionData<typeof AttendeeNoShowUpdatedAuditActionService>;
/**
* Lean base schema for booking audit task payload
*
* Uses `data: z.unknown()` to avoid large discriminated union.
* The consumer parses with this first, then validates `data`
* with the action-specific schema based on the `action` field.
*/
export const BookingAuditTaskBaseSchema = z.object({
bookingUid: z.string(),
actor: ActorSchema,
organizationId: z.number().nullable(),
timestamp: z.number(),
action: BookingAuditActionSchema,
data: z.unknown(),
});
export const BookingAuditTaskConsumerPayloadSchema = z.discriminatedUnion("action", [
baseSchema.merge(z.object({
action: z.literal(CreatedAuditActionService.TYPE),
// Payload in Task record could have any version of the data schema
data: CreatedAuditActionService.storedFieldsSchema,
})),
// ... more actions here
]);
export type BookingAuditTaskProducerActionData =
| { action: typeof CreatedAuditActionService.TYPE; data: z.infer<typeof CreatedAuditActionService.latestFieldsSchema> }
// ... more actions here
export type BookingAuditTaskConsumerPayload = z.infer<typeof BookingAuditTaskConsumerPayloadSchema>;
export type BookingAuditTaskBasePayload = z.infer<typeof BookingAuditTaskBaseSchema>;
@@ -32,6 +32,7 @@ export const ActorSchema = z.discriminatedUnion("identifiedBy", [
export type Actor = z.infer<typeof ActorSchema>;
type UserActor = z.infer<typeof UserActorSchema>;
type GuestActor = z.infer<typeof GuestActorSchema>;
type AttendeeActor = z.infer<typeof AttendeeActorSchema>;
type ActorById = z.infer<typeof ActorByIdSchema>;
/**
@@ -81,5 +82,15 @@ export function makeActorById(id: string): ActorById {
};
}
/**
* Creates an Actor representing an Attendee by attendee ID
*/
export function makeAttendeeActor(attendeeId: number): AttendeeActor {
return {
identifiedBy: "attendee",
attendeeId,
};
}
// System actor ID constant
export const SYSTEM_ACTOR_ID = "00000000-0000-0000-0000-000000000000";
@@ -139,8 +139,8 @@ const buildWhereClauseForActiveBookings = ({
},
...(!includeNoShowInRRCalculation
? {
OR: [{ noShowHost: false }, { noShowHost: null }],
}
OR: [{ noShowHost: false }, { noShowHost: null }],
}
: {}),
},
{
@@ -159,24 +159,24 @@ const buildWhereClauseForActiveBookings = ({
...(startDate || endDate
? rrTimestampBasis === RRTimestampBasis.CREATED_AT
? {
createdAt: {
...(startDate ? { gte: startDate } : {}),
...(endDate ? { lte: endDate } : {}),
},
}
createdAt: {
...(startDate ? { gte: startDate } : {}),
...(endDate ? { lte: endDate } : {}),
},
}
: {
startTime: {
...(startDate ? { gte: startDate } : {}),
...(endDate ? { lte: endDate } : {}),
},
}
startTime: {
...(startDate ? { gte: startDate } : {}),
...(endDate ? { lte: endDate } : {}),
},
}
: {}),
...(virtualQueuesData
? {
routedFromRoutingFormReponse: {
chosenRouteId: virtualQueuesData.chosenRouteId,
},
}
routedFromRoutingFormReponse: {
chosenRouteId: virtualQueuesData.chosenRouteId,
},
}
: {}),
});
@@ -325,7 +325,21 @@ const selectStatementToGetBookingForCalEventBuilder = {
};
export class BookingRepository {
constructor(private prismaClient: PrismaClient) {}
constructor(private prismaClient: PrismaClient) { }
/**
* Gets the fromReschedule field for a booking by UID
* Used to identify if this booking was created from a reschedule
* @param bookingUid - The unique identifier of the booking
* @returns The fromReschedule UID or null if not found/not a rescheduled booking
*/
async getFromRescheduleUid(bookingUid: string): Promise<string | null> {
const booking = await this.prismaClient.booking.findUnique({
where: { uid: bookingUid },
select: { fromReschedule: true },
});
return booking?.fromReschedule ?? null;
}
async getBookingAttendees(bookingId: number) {
return await this.prismaClient.attendee.findMany({
@@ -642,20 +656,20 @@ export class BookingRepository {
const currentBookingsAllUsersQueryThree = eventTypeId
? this.prismaClient.booking.findMany({
where: {
startTime: { lte: endDate },
endTime: { gte: startDate },
eventType: {
id: eventTypeId,
requiresConfirmation: true,
requiresConfirmationWillBlockSlot: true,
},
status: {
in: [BookingStatus.PENDING],
},
where: {
startTime: { lte: endDate },
endTime: { gte: startDate },
eventType: {
id: eventTypeId,
requiresConfirmation: true,
requiresConfirmationWillBlockSlot: true,
},
select: bookingsSelect,
})
status: {
in: [BookingStatus.PENDING],
},
},
select: bookingsSelect,
})
: [];
const [resultOne, resultTwo, resultThree] = await Promise.all([
@@ -0,0 +1,18 @@
import type { BookingAuditViewerService } from "@calcom/features/booking-audit/lib/service/BookingAuditViewerService";
import { BOOKING_AUDIT_DI_TOKENS } from "@calcom/features/booking-audit/di/tokens";
import { DI_TOKENS } from "@calcom/features/di/tokens";
import { prismaModule } from "@calcom/features/di/modules/Prisma";
import { moduleLoader as bookingAuditRepositoryModuleLoader } from "@calcom/features/booking-audit/di/BookingAuditRepository.module";
import { moduleLoader as bookingAuditViewerServiceModuleLoader } from "@calcom/features/booking-audit/di/BookingAuditViewerService.module";
import { createContainer } from "../di";
const container = createContainer();
container.load(DI_TOKENS.PRISMA_MODULE, prismaModule);
bookingAuditRepositoryModuleLoader.loadModule(container);
bookingAuditViewerServiceModuleLoader.loadModule(container);
export function getBookingAuditViewerService() {
return container.get<BookingAuditViewerService>(BOOKING_AUDIT_DI_TOKENS.BOOKING_AUDIT_VIEWER_SERVICE);
}
+2 -2
View File
@@ -1,7 +1,7 @@
import type { z } from "zod";
import type { FORM_SUBMITTED_WEBHOOK_RESPONSES } from "@calcom/app-store/routing-forms/lib/formSubmissionUtils";
import type { BookingAuditTaskConsumerPayload } from "@calcom/features/booking-audit/lib/types/bookingAuditTask";
import type { BookingAuditTaskBasePayload } from "@calcom/features/booking-audit/lib/types/bookingAuditTask";
export type TaskerTypes = "internal" | "redis";
type TaskPayloads = {
sendWebhook: string;
@@ -37,7 +37,7 @@ type TaskPayloads = {
responses?: FORM_SUBMITTED_WEBHOOK_RESPONSES | null;
routedEventTypeId?: number | null;
};
bookingAudit: BookingAuditTaskConsumerPayload;
bookingAudit: BookingAuditTaskBasePayload;
};
export type TaskTypes = keyof TaskPayloads;
export type TaskHandler = (payload: string, taskId?: string) => Promise<void>;