Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
7e9066b
Integrate booking-audit
hariombalhara Nov 3, 2025
803aa1d
Usage of BookingEventHandlerService from container and cleanup of unu…
hariombalhara Nov 3, 2025
8dd0800
ChangeSchema stricter
hariombalhara Nov 3, 2025
388388b
Refactor booking audit actions to use primary/secondary schema structure
hariombalhara Nov 3, 2025
1061fe5
Enhance booking audit schema to include cancellation actor
hariombalhara Nov 3, 2025
ded9091
Refactor booking audit action services to utilize common change schemas
hariombalhara Nov 3, 2025
8fdda0d
feat: Implement audit log backend infrastructure and integrations
hariombalhara Nov 3, 2025
ac026bc
feat: Add audit log viewer UI and API endpoint
hariombalhara Nov 3, 2025
059407e
feat(booking-audit): add DI modules and tokens for actor and booking …
hariombalhara Nov 4, 2025
346f361
refactor(booking-audit): inject dependencies via DI and add logging t…
hariombalhara Nov 4, 2025
39c4461
refactor(di): refactor logger service with DI module and update depen…
hariombalhara Nov 4, 2025
bed4477
refactor(bookings): use DI module loaders for BookingEventHandlerServ…
hariombalhara Nov 4, 2025
ad3b2ba
feat(bookings): add audit logging for rescheduling and pending bookings
hariombalhara Nov 4, 2025
b806432
fix(ui): add text color to log details for better visibility
hariombalhara Nov 4, 2025
20ff717
refactor(booking-audit): simplify schema structure by removing primar…
hariombalhara Nov 10, 2025
02aa5a5
feat(booking-audit): implement per-action versioning for audit action…
hariombalhara Nov 13, 2025
f2b7d26
refactor(booking-audit): update audit action services to use action-s…
hariombalhara Nov 13, 2025
c2ee2db
refactor(booking-audit): remove obsolete foundation document and stre…
hariombalhara Nov 13, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
import type { PageProps } from "app/_types";
import { _generateMetadata, getTranslate } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";

import { getServerSession } from "@calcom/features/auth/lib/getServerSession";

import { buildLegacyRequest } from "@lib/buildLegacyCtx";

import BookingLogsView from "~/booking/logs/views/booking-logs-view";

export const generateMetadata = async ({ params }: { params: Promise<{ bookinguid: string }> }) =>
await _generateMetadata(
(t) => t("booking_history"),
(t) => t("booking_history_description"),
undefined,
undefined,
`/booking/logs/${(await params).bookinguid}`
);

const Page = async ({ params }: PageProps) => {
const resolvedParams = await params;
const bookingUid = resolvedParams.bookinguid;

if (!bookingUid || typeof bookingUid !== "string") {
redirect("/bookings/upcoming");
}

const t = await getTranslate();
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });

if (!session?.user?.id) {
redirect("/auth/login");
}

return (
<ShellMainAppDir heading={t("booking_history")} subtitle={t("booking_history_description")}>
<BookingLogsView bookingUid={bookingUid} />
</ShellMainAppDir>
);
};

export default Page;

292 changes: 292 additions & 0 deletions apps/web/modules/booking/logs/views/booking-logs-view.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,292 @@
"use client";

import { useRouter } from "next/navigation";
import { useState } from "react";

import dayjs from "@calcom/dayjs";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { Icon } from "@calcom/ui/components/icon";
import { SkeletonText } from "@calcom/ui/components/skeleton";

interface BookingLogsViewProps {
bookingUid: string;
}

const _actionColorMap: Record<string, string> = {
CREATED: "blue",
CANCELLED: "red",
ACCEPTED: "green",
REJECTED: "orange",
RESCHEDULED: "purple",
REASSIGNMENT: "yellow",
ATTENDEE_ADDED: "green",
ATTENDEE_REMOVED: "orange",
LOCATION_CHANGED: "blue",
HOST_NO_SHOW_UPDATED: "red",
ATTENDEE_NO_SHOW_UPDATED: "red",
RESCHEDULE_REQUESTED: "purple",
};

const actionDisplayMap: Record<string, string> = {
CREATED: "Created",
CANCELLED: "Cancelled call",
ACCEPTED: "Accepted",
REJECTED: "Rejected",
RESCHEDULED: "Rescheduled call",
REASSIGNMENT: "Assigned",
ATTENDEE_ADDED: "Invited",
ATTENDEE_REMOVED: "Removed attendee",
LOCATION_CHANGED: "Location changed",
HOST_NO_SHOW_UPDATED: "Host no-show updated",
ATTENDEE_NO_SHOW_UPDATED: "Attendee no-show updated",
RESCHEDULE_REQUESTED: "Reschedule requested",
};

const getActionIcon = (action: string) => {
switch (action) {
case "CREATED":
return <Icon name="calendar" className="h-4 w-4" />;
case "CANCELLED":
case "REJECTED":
return <Icon name="ban" className="h-4 w-4" />;
case "ACCEPTED":
return <Icon name="check" className="h-4 w-4" />;
case "RESCHEDULED":
case "RESCHEDULE_REQUESTED":
return <Icon name="calendar" className="h-4 w-4" />;
case "REASSIGNMENT":
case "ATTENDEE_ADDED":
case "ATTENDEE_REMOVED":
return <Icon name="user" className="h-4 w-4" />;
case "LOCATION_CHANGED":
return <Icon name="map-pin" className="h-4 w-4" />;
default:
return <Icon name="settings" className="h-4 w-4" />;
}
};

export default function BookingLogsView({ bookingUid }: BookingLogsViewProps) {
const router = useRouter();
const [expandedLogId, setExpandedLogId] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState("");
const [typeFilter, setTypeFilter] = useState<string | null>(null);
const [actorFilter, setActorFilter] = useState<string | null>(null);

const { data, isLoading, error } = trpc.viewer.bookings.getAuditLogs.useQuery({
bookingUid,
});

const toggleExpand = (logId: string) => {
setExpandedLogId(expandedLogId === logId ? null : logId);
};

if (error) {
return (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<p className="text-red-600 font-medium">Error loading booking logs</p>
<p className="text-sm text-gray-500 mt-2">{error.message}</p>
<Button className="mt-4" onClick={() => router.back()}>
Go Back
</Button>
</div>
</div>
);
}

if (isLoading) {
return (
<div className="space-y-4">
<SkeletonText className="h-12 w-full" />
<SkeletonText className="h-24 w-full" />
<SkeletonText className="h-24 w-full" />
<SkeletonText className="h-24 w-full" />
</div>
);
}

const auditLogs = data?.auditLogs || [];

// Apply filters
const filteredLogs = auditLogs.filter((log) => {
const matchesSearch =
!searchTerm ||
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;
});

const uniqueTypes = Array.from(new Set(auditLogs.map((log) => log.action)));
const uniqueActorTypes = Array.from(new Set(auditLogs.map((log) => log.actor.type)));

return (
<div className="space-y-6">
{/* Header with Back Button */}
<div className="flex items-center gap-4">
<Button variant="icon" onClick={() => router.back()}>
<Icon name="arrow-left" className="h-5 w-5" />
</Button>
<div>
<h2 className="text-xl font-semibold">Booking History</h2>
<p className="text-sm text-gray-500">View all changes and events for this booking</p>
</div>
</div>

{/* Filters */}
<div className="flex flex-wrap gap-4 items-center">
<div className="flex-1 min-w-[200px]">
<input
type="text"
placeholder="Search..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>

<div>
<select
value={typeFilter || ""}
onChange={(e) => setTypeFilter(e.target.value || null)}
className="px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500">
<option value="">Type: All</option>
{uniqueTypes.map((type) => (
<option key={type} value={type}>
{actionDisplayMap[type] || type}
</option>
))}
</select>
</div>

<div>
<select
value={actorFilter || ""}
onChange={(e) => setActorFilter(e.target.value || null)}
className="px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500">
<option value="">Actor: All</option>
{uniqueActorTypes.map((actorType) => (
<option key={actorType} value={actorType}>
{actorType}
</option>
))}
</select>
</div>
</div>

{/* Audit Log List */}
<div className="space-y-3">
{filteredLogs.length === 0 ? (
<div className="text-center py-12">
<p className="text-gray-500">No audit logs found</p>
</div>
) : (
filteredLogs.map((log) => {
const isExpanded = expandedLogId === log.id;
const actionDisplay = actionDisplayMap[log.action] || log.action;

return (
<div
key={log.id}
className="border border-gray-200 rounded-lg p-4 bg-white hover:shadow-md transition-shadow">
{/* Log Header */}
<div className="flex items-start gap-4">
{/* Icon */}
<div className="flex-shrink-0 mt-1">{getActionIcon(log.action)}</div>

{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<h3 className="font-medium text-gray-900">{actionDisplay}</h3>
<div className="flex flex-wrap items-center gap-2 mt-1 text-sm text-gray-500">
<span className="flex items-center gap-1">
<Icon name="user" className="h-3.5 w-3.5" />
{log.actor.displayName}
</span>
<span>•</span>
<span className="flex items-center gap-1">
<Icon name="clock" className="h-3.5 w-3.5" />
{dayjs(log.timestamp).fromNow()}
</span>
</div>
</div>

<button
onClick={() => toggleExpand(log.id)}
className="flex items-center gap-1 text-sm text-blue-600 hover:text-blue-700 font-medium">
{isExpanded ? (
<>
<Icon name="chevron-down" className="h-4 w-4" />
Hide details
</>
) : (
<>
<Icon name="chevron-right" className="h-4 w-4" />
Show details
</>
)}
</button>
</div>
</div>
</div>

{/* Expanded Details */}
{isExpanded && (
<div className="mt-4 pt-4 border-t border-gray-200 space-y-3">
<div className="grid grid-cols-2 gap-x-8 gap-y-2 text-sm">
<div>
<span className="font-medium text-gray-700">Type:</span>
<span className="ml-2 text-gray-600">{log.type}</span>
</div>
<div>
<span className="font-medium text-gray-700">Actor:</span>
<span className="ml-2 text-gray-600">{log.actor.type}</span>
</div>
<div>
<span className="font-medium text-gray-700">Timestamp:</span>
<span className="ml-2 text-gray-600">
{dayjs(log.timestamp).format("YYYY-MM-DD HH:mm:ss")}
</span>
</div>
{log.actor.displayEmail && (
<div className="col-span-2">
<span className="font-medium text-gray-700">Actor Email:</span>
<span className="ml-2 text-gray-600">{log.actor.displayEmail}</span>
</div>
)}
</div>

{/* Data JSON */}
{log.data && (
<div>
<h4 className="font-medium text-gray-700 mb-2">Details:</h4>
<pre className="bg-gray-50 p-3 rounded text-xs overflow-x-auto text-gray-900">
{JSON.stringify(log.data, null, 2)}
</pre>
</div>
)}
</div>
)}
</div>
);
})
)}
</div>

{/* Summary Stats */}
<div className="border-t pt-4 mt-8">
<div className="flex items-center justify-between text-sm text-gray-500">
<span>
Showing {filteredLogs.length} of {auditLogs.length} log entries
</span>
</div>
</div>
</div>
);
}

Loading