Files
calendar/apps/web/modules/booking-audit/components/BookingHistory.tsx
T
Hariom BalharaGitHubunknown <>hariom@cal.com <hariombalhara@gmail.com>hariom@cal.com <hariombalhara@gmail.com>hariom@cal.com <hariombalhara@gmail.com>hariom@cal.com <hariombalhara@gmail.com>hariom@cal.com <hariombalhara@gmail.com>hariom@cal.com <hariombalhara@gmail.com>Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1aae57daae refactor(booking-audit): discriminated union for displayFields and i18n param support (#27373)
* feat: Add infrastructure for no-show audit integration

- Add Prisma migrations for SYSTEM source and NO_SHOW_UPDATED audit action
- Add NoShowUpdatedAuditActionService with array-based attendeesNoShow schema
- Update BookingAuditActionServiceRegistry to include NO_SHOW_UPDATED
- Update BookingAuditTaskConsumer and BookingAuditViewerService
- Add AttendeeRepository methods for no-show queries
- Update IAuditActionService interface with values array support
- Update locales with no-show audit translation keys

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

* fix: Add NO_SHOW_UPDATED to BookingAuditAction and SYSTEM to ActionSource types

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

* fix: Remove HOST_NO_SHOW_UPDATED and ATTENDEE_NO_SHOW_UPDATED from BookingAuditAction type to match Prisma schema

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

* fix: Update BookingAuditActionSchema to use NO_SHOW_UPDATED instead of HOST_NO_SHOW_UPDATED and ATTENDEE_NO_SHOW_UPDATED

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

* refactor: Remove deprecated no-show audit services and unify to NoShowUpdatedAuditActionService

- Delete HostNoShowUpdatedAuditActionService and AttendeeNoShowUpdatedAuditActionService
- Update BookingAuditProducerService.interface.ts to use queueNoShowUpdatedAudit
- Update BookingAuditTaskerProducerService.ts to use queueNoShowUpdatedAudit
- Update BookingEventHandlerService.ts to use onNoShowUpdated
- Add integration tests for NoShowUpdatedAuditActionService

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

* fix: Add data migration step for deprecated no-show enum values

Addresses Cubic AI review feedback (confidence 9/10): The migration now
includes an UPDATE statement to convert existing records using the
deprecated 'host_no_show_updated' or 'attendee_no_show_updated' enum
values to the new unified 'no_show_updated' value before the type cast.
This prevents migration failures if any existing data uses the old values.

Co-Authored-By: unknown <>

* fix: Use CASE expression in USING clause for enum migration

Fixes PostgreSQL error 'unsafe use of new value of enum type' by avoiding
the ADD VALUE statement and instead using a CASE expression in the ALTER
TABLE USING clause to convert deprecated enum values (host_no_show_updated,
attendee_no_show_updated) to the new unified value (no_show_updated) during
the type conversion.

Co-Authored-By: unknown <>

* fix: Replace hardcoded color with semantic text-success class

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

* fix: Remove color class completely from display fields

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

* feat: Add valuesWithParams support for translatable complex field values

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

* refactor(booking-audit): use discriminated union for displayFields and update consumers

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

* fix: add explicit return type to getBookingHistoryHandler to bust stale tRPC build cache

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

* refactor: replace $t() nested interpolation with separate translation keys and add translationsWithParams tests

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-20 14:34:12 +05:30

512 lines
17 KiB
TypeScript

"use client";
import type { AuditActorType } from "@calcom/features/booking-audit/lib/repository/IAuditActorRepository";
import ServerTrans from "@calcom/lib/components/ServerTrans";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Avatar } from "@calcom/ui/components/avatar";
import { Button } from "@calcom/ui/components/button";
import { FilterSearchField, Select } from "@calcom/ui/components/form";
import { Icon, type IconName } from "@calcom/ui/components/icon";
import { SkeletonText } from "@calcom/ui/components/skeleton";
import { Tooltip } from "@calcom/ui/components/tooltip";
import { format, formatDistanceToNow } from "date-fns";
import Link from "next/link";
import { useState } from "react";
interface BookingHistoryProps {
bookingUid: string;
}
type TranslationComponent = {
type: "link";
href: string;
};
type TranslationWithParams = {
key: string;
params?: Record<string, string | number>;
components?: TranslationComponent[];
};
type DisplayFieldValue =
| { type: "translationKey"; valueKey: string }
| { type: "rawValue"; value: string }
| { type: "rawValues"; values: string[] }
| { type: "translationsWithParams"; valuesWithParams: TranslationWithParams[] };
type DisplayField = {
labelKey: string;
fieldValue: DisplayFieldValue;
};
type AuditLog = {
id: string;
action: string;
type: string;
timestamp: string;
source: string;
displayJson?: Record<string, unknown> | null;
actionDisplayTitle: TranslationWithParams;
displayFields?: DisplayField[] | null;
actor: {
type: AuditActorType;
displayName: string | null;
displayEmail: string | null;
displayAvatar: string | null;
};
impersonatedBy?: {
displayName: string;
displayEmail: string | null;
displayAvatar: string | null;
} | null;
hasError?: boolean;
};
interface BookingLogsFiltersProps {
searchTerm: string;
onSearchChange: (value: string) => void;
actorFilter: string | null;
onActorFilterChange: (value: string | null) => void;
actorOptions: Array<{ label: string; value: string }>;
}
interface BookingLogsTimelineProps {
logs: AuditLog[];
}
const ACTION_ICON_MAP: Record<string, IconName> = {
CREATED: "calendar",
CANCELLED: "ban",
REJECTED: "ban",
ACCEPTED: "check",
RESCHEDULED: "pencil",
RESCHEDULE_REQUESTED: "pencil",
REASSIGNMENT: "user-check",
ATTENDEE_ADDED: "user-check",
ATTENDEE_REMOVED: "user-check",
LOCATION_CHANGED: "map-pin",
NO_SHOW_UPDATED: "ban",
} as const;
const ACTOR_ROLE_LABEL_MAP: Record<AuditActorType, string | null> = {
GUEST: "guest",
ATTENDEE: "attendee",
SYSTEM: null,
USER: null,
APP: null,
} as const;
function BookingLogsFilters({
searchTerm,
onSearchChange,
actorFilter,
onActorFilterChange,
actorOptions,
}: BookingLogsFiltersProps) {
const { t } = useLocale();
return (
<div className="flex flex-wrap gap-2 items-start">
<div className="flex-1 min-w-[200px]">
<FilterSearchField
size="sm"
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
containerClassName=""
/>
</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);
}}
options={[{ label: t("all"), value: "" }, ...actorOptions]}
/>
</div>
</div>
);
}
/**
* 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}
target="_blank"
rel="noopener noreferrer"
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-wrap break-all">{line || " "}</span>
</div>
))}
</div>
);
}
interface DisplayFieldValueComponentProps {
fieldValue: DisplayFieldValue;
}
function DisplayFieldValueComponent({ fieldValue }: DisplayFieldValueComponentProps) {
const { t } = useLocale();
switch (fieldValue.type) {
case "translationsWithParams":
return (
<span className="flex flex-col">
{fieldValue.valuesWithParams.map((v, i) => (
<span className="p-0.5" key={i}>
{t(v.key, v.params)}
</span>
))}
</span>
);
case "rawValues":
return (
<span className="flex flex-col">
{fieldValue.values.map((v, i) => (
<span className="p-0.5" key={i}>
{v}
</span>
))}
</span>
);
case "rawValue":
return <>{fieldValue.value}</>;
case "translationKey":
return <>{t(fieldValue.valueKey)}</>;
}
}
function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
const { t } = useLocale();
const [expandedLogIds, setExpandedLogIds] = useState<Set<string>>(new Set());
const [showJsonMap, setShowJsonMap] = useState<Record<string, boolean>>({});
const toggleExpand = (logId: string) => {
setExpandedLogIds((prev) => {
const newSet = new Set(prev);
if (newSet.has(logId)) {
newSet.delete(logId);
} else {
newSet.add(logId);
}
return newSet;
});
};
const toggleJson = (logId: string) => {
setShowJsonMap((prev) => ({
...prev,
[logId]: !prev[logId],
}));
};
if (logs.length === 0) {
return (
<div className="text-center py-12">
<p className="text-muted">{t("no_audit_logs_found")}</p>
</div>
);
}
return (
<div className="flex flex-col gap-0.5">
{logs.map((log, index) => {
const isLast = index === logs.length - 1;
const isExpanded = expandedLogIds.has(log.id);
const showJson = showJsonMap[log.id] || false;
const actorRole = ACTOR_ROLE_LABEL_MAP[log.actor.type] ?? null;
return (
<div key={log.id} className="flex gap-1">
<div className="flex flex-col items-center self-stretch">
<div className="pt-2 shrink-0">
<div className="bg-subtle rounded-[3.556px] p-1 flex items-center justify-center w-4 h-4">
<Icon
name={log.hasError ? "triangle-alert" : (ACTION_ICON_MAP[log.action] ?? "sparkles")}
className={`h-3 w-3 ${log.hasError ? "text-attention" : "text-subtle"}`}
/>
</div>
</div>
{!isLast && <div className="w-px bg-subtle flex-1 min-h-0" />}
</div>
<div className="flex-1 rounded-lg py-2">
<div className="px-3 mb-2">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium leading-4 text-emphasis">
<ActionTitle actionDisplayTitle={log.actionDisplayTitle} />
</h3>
<div className="flex items-center gap-1 mt-1 text-xs text-subtle">
{log.actor.displayAvatar && (
<Avatar
size="xs"
imageSrc={log.actor.displayAvatar}
alt={log.actor.displayName || ""}
/>
)}
<span>
{log.actor.displayName}
{actorRole && <span>{` (${t(actorRole)})`}</span>}
</span>
<span></span>
<Tooltip content={format(new Date(log.timestamp), "yyyy-MM-dd HH:mm:ss")}>
<span>{formatDistanceToNow(new Date(log.timestamp), { addSuffix: true })}</span>
</Tooltip>
</div>
</div>
</div>
</div>
<div className="px-3">
<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 w-[140px]">{t(field.labelKey)}</span>
<span className="font-medium">
<DisplayFieldValueComponent fieldValue={field.fieldValue} />
</span>
</div>
))
: null}
<div className="flex items-start gap-2 py-2 border-b px-3 border-subtle">
<span className="font-medium text-emphasis w-[140px]">{t("actor")}</span>
<span className="text-default">{log.actor.displayName || log.actor.type}</span>
</div>
{log.impersonatedBy && (
<div className="flex items-start gap-2 py-2 border-b px-3 border-subtle">
<span className="font-medium text-emphasis w-[140px]">
{t("booking_audit_action.actor_impersonated_by", {
actor: log.actor.displayName,
})}
</span>
<span className="text-default">{log.impersonatedBy.displayName}</span>
</div>
)}
<div className="flex items-start gap-2 py-2 border-b px-3 border-subtle">
<span className="font-medium text-emphasis w-[140px]">
{t("booking_audit_action.source")}
</span>
<span className="text-default">{log.source}</span>
</div>
<div className="flex items-start gap-2 py-2 px-3 border-b border-subtle">
<span className="font-medium text-emphasis w-[140px]">{t("timestamp")}</span>
<span className="text-default">
{format(new Date(log.timestamp), "yyyy-MM-dd HH:mm:ss")}
</span>
</div>
{log.displayJson && Object.keys(log.displayJson).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.displayJson} />}</div>
</div>
)}
</div>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
);
}
function useBookingLogsFilters(auditLogs: AuditLog[], searchTerm: string, actorFilter: string | null) {
const { t } = useLocale();
const filteredLogs = auditLogs.filter((log) => {
const doesMatchDisplayFields = (): boolean => {
return (
log.displayFields?.some((field) => {
const searchLower = searchTerm.toLowerCase();
const translatedLabel = field.labelKey ? t(field.labelKey) : "";
if (translatedLabel.toLowerCase().includes(searchLower)) {
return true;
}
const { fieldValue } = field;
switch (fieldValue.type) {
case "translationKey":
return t(fieldValue.valueKey).toLowerCase().includes(searchLower);
case "rawValue":
return fieldValue.value.toLowerCase().includes(searchLower);
case "rawValues":
return fieldValue.values.some((v) => v.toLowerCase().includes(searchLower));
case "translationsWithParams":
return fieldValue.valuesWithParams.some(
(v) =>
t(v.key, v.params).toLowerCase().includes(searchLower) ||
Object.values(v.params ?? {}).some((param) =>
param?.toString().toLowerCase().includes(searchLower)
)
);
}
}) ?? false
);
};
const doesMatchActionDisplayTitle = (): boolean => {
const translatedTitle = log.actionDisplayTitle.key
? t(log.actionDisplayTitle.key, log.actionDisplayTitle.params ?? {})
: "";
return translatedTitle.toLowerCase().includes(searchTerm.toLowerCase());
};
const matchesSearch =
!searchTerm ||
log.action.toLowerCase().includes(searchTerm.toLowerCase()) ||
log.actor.displayName?.toLowerCase().includes(searchTerm.toLowerCase()) ||
doesMatchDisplayFields() ||
doesMatchActionDisplayTitle();
const matchesActor = !actorFilter || log.actor.displayName === actorFilter;
return matchesSearch && matchesActor;
});
const uniqueActorNames = Array.from(
new Set(auditLogs.map((log) => log.actor.displayName).filter((name): name is string => Boolean(name)))
);
const actorOptions = uniqueActorNames.map((actorName) => ({
label: actorName,
value: actorName,
}));
return { filteredLogs, actorOptions };
}
export function BookingHistory({ bookingUid }: BookingHistoryProps) {
const [searchTerm, setSearchTerm] = useState("");
const [actorFilter, setActorFilter] = useState<string | null>(null);
const { t } = useLocale();
const { data, isLoading, error } = trpc.viewer.bookings.getBookingHistory.useQuery({
bookingUid,
});
const auditLogs = data?.auditLogs || [];
const { filteredLogs, actorOptions } = useBookingLogsFilters(auditLogs, searchTerm, actorFilter);
if (error) {
return (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<p className="text-red-600 font-medium">{t("error_loading_booking_logs")}</p>
<p className="text-sm text-gray-500 mt-2">{error.message}</p>
</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>
);
}
return (
<div className="space-y-6">
<BookingLogsFilters
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
actorFilter={actorFilter}
onActorFilterChange={setActorFilter}
actorOptions={actorOptions}
/>
<BookingLogsTimeline logs={filteredLogs} />
</div>
);
}