* 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>
512 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|