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:
co-authored by
Devin AI
Udit Takkar
parent
4e1d05a7e7
commit
2218a45d83
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
+7
-2
@@ -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,
|
||||
|
||||
+7
-2
@@ -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;
|
||||
}
|
||||
}
|
||||
+103
-14
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user