feat: call history V1 (#23761)

* feat: call history

* chore

* refactor: move to insights

* fix: types

* chore: improvements

* fix: memberhsip logic

* chore: improvements

* fix: update types

* fix: type errors

* fix: type errors
This commit is contained in:
Udit Takkar
2025-09-14 12:04:47 -03:00
committed by GitHub
parent cfadd2e892
commit 416504240c
20 changed files with 1255 additions and 1 deletions
@@ -0,0 +1,20 @@
import { _generateMetadata } from "app/_utils";
import InsightsCallHistoryPage from "~/insights/insights-call-history-view";
import { checkInsightsPagePermission } from "../checkInsightsPagePermission";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("call_history"),
(t) => t("call_history_subtitle"),
undefined,
undefined,
"/insights/call-history"
);
export default async function Page() {
await checkInsightsPagePermission();
return <InsightsCallHistoryPage />;
}
@@ -0,0 +1,321 @@
"use client";
import { getCoreRowModel, getSortedRowModel, useReactTable, type ColumnDef } from "@tanstack/react-table";
import { useMemo, useState, useReducer } from "react";
import {
DataTableProvider,
DataTableWrapper,
DataTableToolbar,
DataTableFilters,
ColumnFilterType,
convertFacetedValuesToMap,
useDataTable,
} from "@calcom/features/data-table";
import { useSegments } from "@calcom/features/data-table/hooks/useSegments";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import { CallDetailsSheet } from "@calcom/features/ee/workflows/components/CallDetailsSheet";
import type { CallDetailsState, CallDetailsAction } from "@calcom/features/ee/workflows/components/types";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Badge } from "@calcom/ui/components/badge";
type CallHistoryRow = {
id: string;
time: string;
duration: number;
channelType: "web_call" | "phone_call";
sessionId: string;
endReason: string;
sessionStatus: "completed" | "ongoing" | "failed";
userSentiment: "positive" | "neutral" | "negative";
from: string;
to: string;
callCreated: boolean;
inVoicemail: boolean;
};
export type CallHistoryProps = {
org?: RouterOutputs["viewer"]["organizations"]["listCurrent"];
};
const initialState: CallDetailsState = {
callDetailsSheet: {
showModal: false,
},
};
function reducer(state: CallDetailsState, action: CallDetailsAction): CallDetailsState {
switch (action.type) {
case "OPEN_CALL_DETAILS":
return { ...state, callDetailsSheet: action.payload };
case "CLOSE_MODAL":
return {
...state,
callDetailsSheet: { showModal: false },
};
default:
return state;
}
}
function CallHistoryTable(props: CallHistoryProps) {
return (
<DataTableProvider useSegments={useSegments} defaultPageSize={25}>
<CallHistoryContent {...props} />
</DataTableProvider>
);
}
function CallHistoryContent({ org: _org }: CallHistoryProps) {
const orgBranding = useOrgBranding();
const _domain = orgBranding?.fullDomain ?? WEBAPP_URL;
const { t } = useLocale();
const [rowSelection, setRowSelection] = useState({});
const [state, dispatch] = useReducer(reducer, initialState);
const { limit, offset, searchTerm: _searchTerm } = useDataTable();
const {
data: callsData,
isPending: isLoadingCalls,
error: _callsError,
} = trpc.viewer.aiVoiceAgent.listCalls.useQuery({
limit,
offset,
filters: {},
});
const callHistoryData: CallHistoryRow[] = useMemo(() => {
if (!callsData?.calls) return [];
return callsData.calls.map((call) => ({
id: call.call_id || Math.random().toString(),
time: call.start_timestamp ? new Date(call.start_timestamp).toISOString() : new Date().toISOString(),
duration: Math.round((call.duration_ms || 0) / 1000),
channelType: (call.call_type || "phone_call") as "web_call" | "phone_call",
sessionId: call.call_id || t("unknown"),
endReason: call.disconnection_reason || t("unknown"),
sessionStatus:
call.call_status === "ended" ? "completed" : call.call_status === "ongoing" ? "ongoing" : "failed",
userSentiment:
call.call_analysis?.user_sentiment?.toLowerCase() === "positive"
? "positive"
: call.call_analysis?.user_sentiment?.toLowerCase() === "negative"
? "negative"
: "neutral",
from: "from_number" in call ? call.from_number || t("unknown") : t("unknown"),
to: "to_number" in call ? call.to_number || t("unknown") : t("unknown"),
callCreated: call.call_analysis?.call_successful ?? true,
inVoicemail: call.call_analysis?.in_voicemail ?? false,
}));
}, [callsData?.calls]);
const columns = useMemo<ColumnDef<CallHistoryRow>[]>(
() => [
{
id: "time",
accessorKey: "time",
header: t("time_header"),
size: 150,
cell: ({ row }) => {
const date = new Date(row.original.time);
return (
<div className="text-sm">
<div>{date.toLocaleDateString()}</div>
<div className="text-subtle">{date.toLocaleTimeString()}</div>
</div>
);
},
},
{
id: "duration",
accessorKey: "duration",
header: t("duration"),
size: 140,
cell: ({ row }) => {
const seconds = row.original.duration;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return <span>{`${minutes}:${remainingSeconds.toString().padStart(2, "0")}`}</span>;
},
},
{
id: "channelType",
accessorKey: "channelType",
header: t("channel_type"),
size: 160,
meta: {
filter: { type: ColumnFilterType.MULTI_SELECT },
},
cell: ({ row }) => <span>{row.original.channelType}</span>,
},
{
id: "sessionId",
accessorKey: "sessionId",
header: t("session_id"),
size: 210,
cell: ({ row }) => <code className="text-xs">{row.original.sessionId}</code>,
},
{
id: "endReason",
accessorKey: "endReason",
header: t("end_reason"),
size: 180,
cell: ({ row }) => <span>{row.original.endReason}</span>,
},
{
id: "sessionStatus",
accessorKey: "sessionStatus",
header: t("session_status"),
size: 200,
meta: {
filter: { type: ColumnFilterType.MULTI_SELECT },
},
cell: ({ row }) => {
const status = row.original.sessionStatus;
const variant = status === "completed" ? "green" : status === "ongoing" ? "blue" : "red";
return <Badge variant={variant}>{status}</Badge>;
},
},
{
id: "userSentiment",
accessorKey: "userSentiment",
header: t("user_sentiment"),
size: 200,
meta: {
filter: { type: ColumnFilterType.MULTI_SELECT },
},
cell: ({ row }) => {
const sentiment = row.original.userSentiment;
const variant = sentiment === "positive" ? "green" : sentiment === "negative" ? "red" : "gray";
return <Badge variant={variant}>{sentiment}</Badge>;
},
},
{
id: "from",
accessorKey: "from",
header: t("from_header"),
size: 140,
},
{
id: "to",
accessorKey: "to",
header: t("to"),
size: 140,
},
{
id: "callCreated",
accessorKey: "callCreated",
header: t("call_created"),
size: 200,
cell: ({ row }) => {
const created = row.original.callCreated;
const variant = created ? "green" : "red";
return <Badge variant={variant}>{created ? t("successful") : t("unsuccessful")}</Badge>;
},
},
{
id: "inVoicemail",
accessorKey: "inVoicemail",
header: t("voicemail"),
size: 150,
cell: ({ row }) => {
const inVoicemail = row.original.inVoicemail;
const variant = inVoicemail ? "blue" : "gray";
return <Badge variant={variant}>{inVoicemail ? t("yes") : t("no")}</Badge>;
},
},
],
[t]
);
const table = useReactTable({
data: callHistoryData,
columns,
enableRowSelection: false,
manualPagination: true,
state: {
rowSelection,
},
initialState: {
columnPinning: {
left: ["time"],
},
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
onRowSelectionChange: setRowSelection,
getRowId: (row) => row.id,
getFacetedUniqueValues: (_, columnId) => () => {
switch (columnId) {
case "channelType":
return convertFacetedValuesToMap([
{ label: "Web Call", value: "web_call" },
{ label: "Phone Call", value: "phone_call" },
]);
case "sessionStatus":
return convertFacetedValuesToMap([
{ label: "Completed", value: "completed" },
{ label: "Ongoing", value: "ongoing" },
{ label: "Failed", value: "failed" },
]);
case "userSentiment":
return convertFacetedValuesToMap([
{ label: "Positive", value: "positive" },
{ label: "Neutral", value: "neutral" },
{ label: "Negative", value: "negative" },
]);
default:
return new Map();
}
},
});
return (
<>
<DataTableWrapper<CallHistoryRow>
testId="call-history-data-table"
table={table}
isPending={isLoadingCalls}
totalRowCount={callsData?.totalCount || 0}
paginationMode="standard"
rowClassName="cursor-pointer hover:bg-subtle"
onRowMouseclick={(row) => {
const callIndex = callHistoryData.findIndex((call) => call.id === row.original.id);
if (callIndex !== -1 && callsData?.calls?.[callIndex]) {
dispatch({
type: "OPEN_CALL_DETAILS",
payload: {
showModal: true,
selectedCall: callsData.calls[callIndex],
},
});
}
}}
ToolbarLeft={
<>
<DataTableToolbar.SearchBar />
<DataTableFilters.ColumnVisibilityButton table={table} />
{/* <DataTableFilters.FilterBar table={table} /> */}
</>
}
ToolbarRight={
<>
<DataTableFilters.ClearFiltersButton />
</>
}
/>
{state.callDetailsSheet.showModal && <CallDetailsSheet state={state} dispatch={dispatch} />}
</>
);
}
export default function InsightsCallHistoryPage() {
const { data: org } = trpc.viewer.organizations.listCurrent.useQuery();
return <CallHistoryTable org={org} />;
}
@@ -2485,7 +2485,15 @@
"insights_team_filter": "Team: {{teamName}}",
"insights_user_filter": "User: {{userName}}",
"insights_subtitle": "View booking insights across your events",
"call_history": "Call History",
"call_history_subtitle": "View call history across your Cal.ai calls",
"location_options": "{{locationCount}} location options",
"channel_type": "Channel Type",
"end_reason": "End Reason",
"session_status": "Session Status",
"user_sentiment": "User Sentiment",
"time_header": "Time",
"from_header": "From",
"custom_plan": "Custom Plan",
"email_embed": "Email Embed",
"add_times_to_your_email": "Select a few available times and embed them in your Email",
@@ -3649,6 +3657,9 @@
"visit": "Visit",
"location_custom_label_input_label": "Custom label on booking page",
"meeting_link": "Meeting link",
"session_outcome": "Session Outcome",
"call_created": "Call Created",
"voicemail": "Voicemail",
"my_bookings": "My Bookings",
"phone": "Phone",
"free": "Free",
@@ -3678,6 +3689,16 @@
"before_scheduled_start_time": "Before scheduled start time",
"cancel_booking_acknowledge_no_show_fee": "I acknowledge that by cancelling the booking within {{timeValue}} {{timeUnit}} of the start time I will be charged the no show fee of {{amount, currency}}",
"contact_organizer": "If you have any questions, please contact the organizer.",
"call_details": "Call Details",
"call_id": "Call ID",
"call_information": "Call Information",
"sentiment": "Sentiment",
"disconnect_reason": "Disconnect Reason",
"call_summary": "Call Summary",
"transcription": "Transcription",
"event_details": "Event Details",
"agent": "Agent",
"no_transcript_available": "No transcript available",
"testing_sms_workflow_info_message": "When testing this workflow, be aware that SMS need to be scheduled at least 15 minutes in advance",
"start_from_scratch_title": "Start from scratch",
"start_from_scratch_description": "Create your own workflow from scratch.",
+2
View File
@@ -45,6 +45,8 @@ export type {
AIConfigurationSetup,
AIConfigurationDeletion,
DeletionResult,
RetellCallListParams,
RetellCallListResponse,
} from "./providers/retellAI";
// Legacy exports for backward compatibility
@@ -89,6 +89,13 @@ export type AIPhoneServiceAgentWithDetails<
T extends AIPhoneServiceProviderType = AIPhoneServiceProviderType
> = AIPhoneServiceProviderTypeMap[T]["AgentWithDetails"];
export type AIPhoneServiceListCallsParams<T extends AIPhoneServiceProviderType = AIPhoneServiceProviderType> =
AIPhoneServiceProviderTypeMap[T]["ListCallsParams"];
export type AIPhoneServiceListCallsResponse<
T extends AIPhoneServiceProviderType = AIPhoneServiceProviderType
> = AIPhoneServiceProviderTypeMap[T]["ListCallsResponse"];
export interface AIPhoneServiceDeletion {
modelId?: string;
agentId?: string;
@@ -354,6 +361,19 @@ export interface AIPhoneServiceProvider<T extends AIPhoneServiceProviderType = A
* Remove tools for event types
*/
removeToolsForEventTypes(agentId: string, eventTypeIds: number[]): Promise<void>;
/**
* List calls with optional filters
*/
listCalls(params: {
limit?: number;
offset?: number;
filters: {
fromNumber: string[];
toNumber?: string[];
startTimestamp?: { lower_threshold?: number; upper_threshold?: number };
};
}): Promise<AIPhoneServiceListCallsResponse<T>>;
}
/**
@@ -290,4 +290,16 @@ export class RetellAIPhoneServiceProvider
async removeToolsForEventTypes(agentId: string, eventTypeIds: number[]): Promise<void> {
return await this.service.removeToolsForEventTypes(agentId, eventTypeIds);
}
async listCalls(params: {
limit?: number;
offset?: number;
filters: {
fromNumber: string[];
toNumber?: string[];
startTimestamp?: { lower_threshold?: number; upper_threshold?: number };
};
}) {
return await this.service.listCalls(params);
}
}
@@ -242,4 +242,16 @@ export class RetellAIService {
async cancelPhoneNumberSubscription(params: { phoneNumberId: number; userId: number; teamId?: number }) {
return this.billingService.cancelPhoneNumberSubscription(params);
}
async listCalls(params: {
limit?: number;
offset?: number;
filters: {
fromNumber: string[];
toNumber?: string[];
startTimestamp?: { lower_threshold?: number; upper_threshold?: number };
};
}) {
return this.callService.listCalls(params);
}
}
@@ -14,6 +14,8 @@ import type {
CreatePhoneCallParams,
CreateWebCallParams,
ImportPhoneNumberParams,
RetellCallListParams,
RetellCallListResponse,
} from "./types";
const RETELL_API_KEY = process.env.RETELL_AI_KEY;
@@ -236,6 +238,26 @@ export class RetellSDKClient implements RetellAIRepository {
}
}
async listCalls(params: RetellCallListParams): Promise<RetellCallListResponse> {
try {
this.logger.info("Listing calls via SDK", {
limit: params.limit,
hasFilters: !!params.filter_criteria,
});
const response = await this.client.call.list(params);
this.logger.info("Calls listed successfully", {
count: response.length,
});
return response;
} catch (error) {
this.logger.error("Failed to list calls", { error });
throw error;
}
}
async createWebCall(data: CreateWebCallParams) {
try {
const response = await this.client.call.createWebCall({
@@ -19,6 +19,8 @@ import type {
RetellAgentWithDetails,
Language,
RetellDynamicVariables,
RetellCallListParams,
RetellCallListResponse,
} from "./types";
export { RetellAIService } from "./RetellAIService";
@@ -61,6 +63,8 @@ export type {
RetellAgentWithDetails,
Language,
RetellDynamicVariables,
RetellCallListParams,
RetellCallListResponse,
};
export interface RetellAIPhoneServiceProviderTypeMap {
@@ -77,6 +81,8 @@ export interface RetellAIPhoneServiceProviderTypeMap {
Tools: RetellLLMGeneralTools;
CreatePhoneCallParams: { fromNumber: string; toNumber: string; dynamicVariables?: RetellDynamicVariables };
AgentWithDetails: RetellAgentWithDetails;
ListCallsParams: RetellCallListParams;
ListCallsResponse: RetellCallListResponse;
}
// ===== USAGE EXAMPLES =====
@@ -7,7 +7,7 @@ import type {
AIPhoneServiceCall,
} from "../../../interfaces/AIPhoneService.interface";
import type { AgentRepositoryInterface } from "../../interfaces/AgentRepositoryInterface";
import type { RetellAIRepository, RetellDynamicVariables } from "../types";
import type { RetellAIRepository, RetellDynamicVariables, RetellCallListResponse } from "../types";
interface RetellAIServiceInterface {
updateToolsFromAgentId(
@@ -286,4 +286,55 @@ export class CallService {
});
}
}
async listCalls({
limit = 50,
offset: _offset = 0,
filters,
}: {
limit?: number;
offset?: number;
filters: {
fromNumber: string[];
toNumber?: string[];
startTimestamp?: { lower_threshold?: number; upper_threshold?: number };
};
}): Promise<RetellCallListResponse> {
try {
if (filters.fromNumber.length === 0) {
this.logger.info("No phone numbers provided");
return [];
}
const callsResponse = await this.retellRepository.listCalls({
filter_criteria: {
from_number: filters.fromNumber,
...(filters?.toNumber && { to_number: filters.toNumber }),
...(filters?.startTimestamp && { start_timestamp: filters.startTimestamp }),
},
limit,
sort_order: "descending",
});
return callsResponse.map((call) => {
const { transcript_object: _transcript_object, call_cost: _call_cost, ...filteredCall } = call;
return {
...filteredCall,
sessionOutcome:
call.call_status === "ended" && !call.disconnection_reason?.includes("error")
? "successful"
: "unsuccessful",
};
}) as RetellCallListResponse;
} catch (error) {
this.logger.error("Failed to list calls", {
phoneNumbers: filters?.fromNumber,
error,
});
throw new HttpError({
statusCode: 500,
message: "Failed to retrieve call history",
});
}
}
}
@@ -9,6 +9,10 @@ export type RetellDynamicVariables = { [key: string]: unknown };
export type RetellAgent = Retell.AgentResponse;
// Call list types
export type RetellCallListParams = Retell.CallListParams;
export type RetellCallListResponse = Retell.CallListResponse;
export type RetellAgentWithRetellLm = Retell.AgentResponse & {
response_engine: Retell.AgentResponse.ResponseEngineRetellLm;
};
@@ -176,6 +180,9 @@ export interface RetellAIRepository {
// Call operations
createPhoneCall(data: CreatePhoneCallParams): Promise<RetellCall>;
listCalls(params: RetellCallListParams): Promise<RetellCallListResponse>;
createWebCall(
data: CreateWebCallParams
): Promise<{ call_id: string; access_token: string; agent_id: string }>;
@@ -0,0 +1,182 @@
import type { Dispatch } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import { Icon } from "@calcom/ui/components/icon";
import {
Sheet,
SheetContent,
SheetBody,
SheetHeader,
SheetFooter,
SheetClose,
} from "@calcom/ui/components/sheet";
import type { CallDetailsAction, CallDetailsState } from "./types";
interface CallDetailsSheetProps {
state: CallDetailsState;
dispatch: Dispatch<CallDetailsAction>;
}
export function CallDetailsSheet({ state, dispatch }: CallDetailsSheetProps) {
const { t } = useLocale();
const { selectedCall } = state.callDetailsSheet;
if (!selectedCall) return null;
const formatDuration = (durationMs: number) => {
const totalSeconds = Math.round(durationMs / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
};
const formatTimestamp = (timestamp: number) => {
const date = new Date(timestamp);
return date.toLocaleString();
};
return (
<Sheet
open={true}
onOpenChange={() => {
dispatch({ type: "CLOSE_MODAL" });
}}>
<SheetContent className="bg-default max-w-2xl overflow-y-auto">
<SheetHeader showCloseButton={true}>
<div className="mb-4 flex items-center justify-between">
<div>
<h2 className="text-emphasis font-sans text-xl font-semibold">{t("call_details")}</h2>
<p className="text-subtle mt-1 text-sm">
{t("call_id")}: {selectedCall.call_id}{" "}
<Badge variant={selectedCall.call_status === "ended" ? "green" : "orange"}>
{selectedCall.call_status}
</Badge>
</p>
</div>
</div>
</SheetHeader>
<SheetBody className="flex flex-col space-y-6 p-4">
{/* Call Information */}
<div className="space-y-3">
<h3 className="text-emphasis text-base font-semibold">{t("call_information")}</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-subtle text-sm">{t("start_time")}</p>
<p className="text-default text-sm font-medium">
{selectedCall.start_timestamp ? formatTimestamp(selectedCall.start_timestamp) : t("unknown")}
</p>
</div>
<div>
<p className="text-subtle text-sm">{t("duration")}</p>
<p className="text-default text-sm font-medium">{selectedCall.duration_ms ? formatDuration(selectedCall.duration_ms) : t("unknown")}</p>
</div>
<div>
<p className="text-subtle text-sm">{t("from")}</p>
<p className="text-default text-sm font-medium">{"from_number" in selectedCall ? selectedCall.from_number || t("unknown") : t("unknown")}</p>
</div>
<div>
<p className="text-subtle text-sm">{t("to")}</p>
<p className="text-default text-sm font-medium">{"to_number" in selectedCall ? selectedCall.to_number || t("unknown") : t("unknown")}</p>
</div>
<div>
<p className="text-subtle text-sm">{t("sentiment")}</p>
<Badge
variant={
selectedCall.call_analysis?.user_sentiment === "Positive"
? "green"
: selectedCall.call_analysis?.user_sentiment === "Negative"
? "red"
: "gray"
}>
{selectedCall.call_analysis?.user_sentiment || "Unknown"}
</Badge>
</div>
<div>
<p className="text-subtle text-sm">{t("disconnect_reason")}</p>
<p className="text-default text-sm font-medium">
{selectedCall.disconnection_reason?.replace(/_/g, " ")}
</p>
</div>
</div>
</div>
{/* Call Summary */}
{selectedCall.call_analysis?.call_summary && (
<div className="space-y-3">
<h3 className="text-emphasis text-base font-semibold">{t("call_summary")}</h3>
<p className="text-default text-sm">{selectedCall.call_analysis.call_summary}</p>
</div>
)}
{/* Recording Section */}
<div className="space-y-3">
<h3 className="text-emphasis text-base font-semibold">{t("recording")}</h3>
<div className="flex items-center gap-3">
<audio controls className="h-11 w-[258px]" src={selectedCall.recording_url}>
Your browser does not support the audio element.
</audio>
<Button color="minimal" href={selectedCall.recording_url} target="_blank" StartIcon="download">
{t("download")}
</Button>
</div>
</div>
{/* Transcription */}
<div className="space-y-3">
<h3 className="text-emphasis text-base font-semibold">{t("transcription")}</h3>
<div className="border-subtle max-h-96 space-y-3 overflow-y-auto rounded-md border p-4">
{selectedCall.transcript ? (
selectedCall.transcript
.split("\n")
.filter((line) => line.trim())
.map((line, index) => {
const isAgent = line.startsWith("Agent:");
const isUser = line.startsWith("User:");
const content = line.replace(/^(Agent:|User:)\s*/, "");
if (!isAgent && !isUser) return null;
return (
<div key={index} className="space-y-1">
<div className="flex items-center gap-2">
<Icon name="user" className={isAgent ? "text-info" : "text-success"} />
<span className={`text-sm font-medium ${isAgent ? "text-info" : "text-success"}`}>
{isAgent ? t("agent") : t("user")}
</span>
</div>
<p className="text-default ml-7 text-sm">{content}</p>
</div>
);
})
) : (
<p className="text-subtle text-sm">{t("no_transcript_available")}</p>
)}
</div>
</div>
{/* Event Details */}
{selectedCall.retell_llm_dynamic_variables && (
<div className="space-y-3">
<h3 className="text-emphasis text-base font-semibold">{t("event_details")}</h3>
<div className="border-subtle bg-muted rounded-md border p-4">
<pre className="text-default overflow-x-auto text-xs">
{JSON.stringify(selectedCall.retell_llm_dynamic_variables, null, 2)}
</pre>
</div>
</div>
)}
</SheetBody>
<SheetFooter>
<SheetClose asChild>
<Button color="secondary">{t("close")}</Button>
</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,21 @@
import type { RouterOutputs } from "@calcom/trpc/react";
export type CallData = RouterOutputs["viewer"]["aiVoiceAgent"]["listCalls"]["calls"][number];
export type CallDetailsPayload = {
showModal: boolean;
selectedCall?: CallData;
};
export type CallDetailsState = {
callDetailsSheet: CallDetailsPayload;
};
export type CallDetailsAction =
| {
type: "OPEN_CALL_DETAILS";
payload: CallDetailsPayload;
}
| {
type: "CLOSE_MODAL";
};
@@ -0,0 +1,326 @@
"use client";
import { getCoreRowModel, getSortedRowModel, useReactTable, type ColumnDef } from "@tanstack/react-table";
import { useMemo, useState, useReducer } from "react";
import {
DataTableProvider,
DataTableWrapper,
DataTableToolbar,
DataTableFilters,
useColumnFilters,
ColumnFilterType,
convertFacetedValuesToMap,
useDataTable,
} from "@calcom/features/data-table";
import { useSegments } from "@calcom/features/data-table/hooks/useSegments";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Badge } from "@calcom/ui/components/badge";
import { CallDetailsSheet } from "../components/CallDetailsSheet";
import type { CallDetailsState, CallDetailsAction } from "../components/types";
type CallHistoryRow = {
id: string;
time: string;
duration: number;
channelType: "web_call" | "phone_call";
sessionId: string;
endReason: string;
sessionStatus: "completed" | "ongoing" | "failed";
userSentiment: "positive" | "neutral" | "negative";
from: string;
to: string;
callCreated: boolean;
inVoicemail: boolean;
};
export type CallHistoryProps = {
org?: RouterOutputs["viewer"]["organizations"]["listCurrent"];
};
const initialState: CallDetailsState = {
callDetailsSheet: {
showModal: false,
},
};
function reducer(state: CallDetailsState, action: CallDetailsAction): CallDetailsState {
switch (action.type) {
case "OPEN_CALL_DETAILS":
return { ...state, callDetailsSheet: action.payload };
case "CLOSE_MODAL":
return {
...state,
callDetailsSheet: { showModal: false },
};
default:
return state;
}
}
function CallHistoryTable(props: CallHistoryProps) {
return (
<DataTableProvider useSegments={useSegments} defaultPageSize={25}>
<CallHistoryContent {...props} />
</DataTableProvider>
);
}
function CallHistoryContent({ org: _org }: CallHistoryProps) {
const orgBranding = useOrgBranding();
const _domain = orgBranding?.fullDomain ?? WEBAPP_URL;
const { t } = useLocale();
const [rowSelection, setRowSelection] = useState({});
const [state, dispatch] = useReducer(reducer, initialState);
const _columnFilters = useColumnFilters();
const { limit, offset, searchTerm: _searchTerm } = useDataTable();
// Fetch calls data from API
const {
data: callsData,
isPending: isLoadingCalls,
error: _callsError,
} = trpc.viewer.aiVoiceAgent.listCalls.useQuery({
limit,
offset,
filters: {},
});
const callHistoryData: CallHistoryRow[] = useMemo(() => {
if (!callsData?.calls) return [];
return callsData.calls.map((call) => ({
id: call.call_id || call.id || Math.random().toString(),
time: call.start_timestamp ? new Date(call.start_timestamp).toISOString() : new Date().toISOString(),
duration: Math.round((call.duration_ms || 0) / 1000),
channelType: (call.call_type || "phone_call") as "web_call" | "phone_call",
sessionId: call.call_id || call.id || "unknown",
endReason: call.disconnection_reason || "Unknown",
sessionStatus:
call.call_status === "ended" ? "completed" : call.call_status === "ongoing" ? "ongoing" : "failed",
userSentiment:
call.call_analysis?.user_sentiment?.toLowerCase() === "positive"
? "positive"
: call.call_analysis?.user_sentiment?.toLowerCase() === "negative"
? "negative"
: "neutral",
from: call.from_number || "Unknown",
to: call.to_number || "Unknown",
callCreated: call.call_created ?? true,
inVoicemail: call.in_voicemail ?? false,
}));
}, [callsData?.calls]);
const columns = useMemo<ColumnDef<CallHistoryRow>[]>(
() => [
{
id: "time",
accessorKey: "time",
header: t("time_header"),
size: 150,
cell: ({ row }) => {
const date = new Date(row.original.time);
return (
<div className="text-sm">
<div>{date.toLocaleDateString()}</div>
<div className="text-subtle">{date.toLocaleTimeString()}</div>
</div>
);
},
},
{
id: "duration",
accessorKey: "duration",
header: t("duration"),
size: 140,
cell: ({ row }) => {
const seconds = row.original.duration;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return <span>{`${minutes}:${remainingSeconds.toString().padStart(2, "0")}`}</span>;
},
},
{
id: "channelType",
accessorKey: "channelType",
header: t("channel_type"),
size: 160,
meta: {
filter: { type: ColumnFilterType.MULTI_SELECT },
},
cell: ({ row }) => <span>{row.original.channelType}</span>,
},
{
id: "sessionId",
accessorKey: "sessionId",
header: t("session_id"),
size: 210,
cell: ({ row }) => <code className="text-xs">{row.original.sessionId}</code>,
},
{
id: "endReason",
accessorKey: "endReason",
header: t("end_reason"),
size: 180,
},
{
id: "sessionStatus",
accessorKey: "sessionStatus",
header: t("session_status"),
size: 200,
meta: {
filter: { type: ColumnFilterType.MULTI_SELECT },
},
cell: ({ row }) => {
const status = row.original.sessionStatus;
const variant = status === "completed" ? "green" : status === "ongoing" ? "blue" : "red";
return <Badge variant={variant}>{status}</Badge>;
},
},
{
id: "userSentiment",
accessorKey: "userSentiment",
header: t("user_sentiment"),
size: 200,
meta: {
filter: { type: ColumnFilterType.MULTI_SELECT },
},
cell: ({ row }) => {
const sentiment = row.original.userSentiment;
const variant = sentiment === "positive" ? "green" : sentiment === "negative" ? "red" : "gray";
return <Badge variant={variant}>{sentiment}</Badge>;
},
},
{
id: "from",
accessorKey: "from",
header: t("from_header"),
size: 140,
},
{
id: "to",
accessorKey: "to",
header: t("to"),
size: 140,
},
{
id: "callCreated",
accessorKey: "callCreated",
header: t("call_created"),
size: 200,
cell: ({ row }) => {
const created = row.original.callCreated;
const variant = created ? "green" : "red";
return <Badge variant={variant}>{created ? t("successful") : t("unsuccessful")}</Badge>;
},
},
{
id: "inVoicemail",
accessorKey: "inVoicemail",
header: t("voicemail"),
size: 150,
cell: ({ row }) => {
const inVoicemail = row.original.inVoicemail;
const variant = inVoicemail ? "blue" : "gray";
return <Badge variant={variant}>{inVoicemail ? t("yes") : t("no")}</Badge>;
},
},
],
[t]
);
const table = useReactTable({
data: callHistoryData,
columns,
enableRowSelection: false,
manualPagination: true,
state: {
rowSelection,
},
initialState: {
columnPinning: {
left: ["time"],
},
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
onRowSelectionChange: setRowSelection,
getRowId: (row) => row.id,
getFacetedUniqueValues: (_, columnId) => () => {
switch (columnId) {
case "channelType":
return convertFacetedValuesToMap([
{ label: "Web Call", value: "web_call" },
{ label: "Phone Call", value: "phone_call" },
]);
case "sessionStatus":
return convertFacetedValuesToMap([
{ label: "Completed", value: "completed" },
{ label: "Ongoing", value: "ongoing" },
{ label: "Failed", value: "failed" },
]);
case "userSentiment":
return convertFacetedValuesToMap([
{ label: "Positive", value: "positive" },
{ label: "Neutral", value: "neutral" },
{ label: "Negative", value: "negative" },
]);
default:
return new Map();
}
},
});
return (
<>
<DataTableWrapper<CallHistoryRow>
testId="call-history-data-table"
table={table}
isPending={isLoadingCalls}
totalRowCount={callsData?.totalCount || 0}
paginationMode="standard"
rowClassName="cursor-pointer hover:bg-subtle"
onRowMouseclick={(row) => {
const callIndex = callHistoryData.findIndex((call) => call.id === row.original.id);
if (callIndex !== -1 && callsData?.calls?.[callIndex]) {
dispatch({
type: "OPEN_CALL_DETAILS",
payload: {
showModal: true,
selectedCall: callsData.calls[callIndex],
},
});
}
}}
ToolbarLeft={
<>
<DataTableToolbar.SearchBar />
<DataTableFilters.ColumnVisibilityButton table={table} />
<DataTableFilters.FilterBar table={table} />
</>
}
ToolbarRight={
<>
<DataTableFilters.ClearFiltersButton />
</>
}
/>
{state.callDetailsSheet.showModal && <CallDetailsSheet state={state} dispatch={dispatch} />}
</>
);
}
function CallHistoryPage() {
const { data: org } = trpc.viewer.organizations.listCurrent.useQuery();
return <CallHistoryTable org={org} />;
}
export default CallHistoryPage;
@@ -122,6 +122,12 @@ const getNavigationItems = (orgBranding: OrganizationBranding): NavigationItemTy
href: "/insights/router-position",
isCurrent: ({ pathname: path }) => path?.startsWith("/insights/router-position") ?? false,
},
{
name: "call_history",
href: "/insights/call-history",
// icon: "phone",
isCurrent: ({ pathname: path }) => path?.startsWith("/insights/call-history") ?? false,
},
],
},
];
@@ -0,0 +1,54 @@
import { prisma } from "@calcom/prisma";
export class CalAiPhoneNumberRepository {
static async getUserPhoneNumbers(userId: number) {
return prisma.calAiPhoneNumber.findMany({
where: { userId },
select: { phoneNumber: true },
});
}
static async getOrganizationTeamPhoneNumbers(organizationId: number) {
return prisma.calAiPhoneNumber.findMany({
where: {
team: { parentId: organizationId },
},
select: { phoneNumber: true },
});
}
static async getTeamPhoneNumbers(teamIds: number[]) {
return prisma.calAiPhoneNumber.findMany({
where: { teamId: { in: teamIds } },
select: { phoneNumber: true },
});
}
static async getAccessiblePhoneNumbers({
userId,
organizationId,
isOrgOwner,
adminTeamIds,
}: {
userId: number;
organizationId?: number;
isOrgOwner: boolean;
adminTeamIds: number[];
}): Promise<string[]> {
const userPhoneNumbers = await this.getUserPhoneNumbers(userId);
let teamPhoneNumbers: Array<{ phoneNumber: string }> = [];
if (isOrgOwner && organizationId) {
teamPhoneNumbers = await this.getOrganizationTeamPhoneNumbers(organizationId);
} else if (adminTeamIds.length > 0) {
teamPhoneNumbers = await this.getTeamPhoneNumbers(adminTeamIds);
}
const allPhoneNumbers = [
...userPhoneNumbers.map((p) => p.phoneNumber),
...teamPhoneNumbers.map((p) => p.phoneNumber),
];
return Array.from(new Set(allPhoneNumbers));
}
}
@@ -477,6 +477,35 @@ export class MembershipRepository {
return teams;
}
static async findAllByUserId({
userId,
filters,
}: {
userId: number;
filters?: {
accepted?: boolean;
roles?: MembershipRole[];
};
}) {
return prisma.membership.findMany({
where: {
userId,
...(filters?.accepted !== undefined && { accepted: filters.accepted }),
...(filters?.roles && { role: { in: filters.roles } }),
},
select: {
teamId: true,
role: true,
team: {
select: {
id: true,
parentId: true,
},
},
},
});
}
async findTeamAdminsByTeamId({ teamId }: { teamId: number }) {
return await this.prismaClient.membership.findMany({
where: {
@@ -6,6 +6,7 @@ import { ZCreateWebCallInputSchema } from "./createWebCall.schema";
import { ZDeleteInputSchema } from "./delete.schema";
import { ZGetInputSchema } from "./get.schema";
import { ZListInputSchema } from "./list.schema";
import { ZListCallsInputSchema } from "./listCalls.schema";
import { ZTestCallInputSchema } from "./testCall.schema";
import { ZUpdateInputSchema } from "./update.schema";
@@ -64,6 +65,15 @@ export const aiVoiceAgentRouter = router({
});
}),
listCalls: authedProcedure.input(ZListCallsInputSchema).query(async ({ ctx, input }) => {
const { listCallsHandler } = await import("./listCalls.handler");
return listCallsHandler({
ctx,
input,
});
}),
createWebCall: eventOwnerProcedure.input(ZCreateWebCallInputSchema).mutation(async ({ ctx, input }) => {
const { createWebCallHandler } = await import("./createWebCall.handler");
@@ -0,0 +1,117 @@
import { createDefaultAIPhoneServiceProvider } from "@calcom/features/calAIPhone";
import logger from "@calcom/lib/logger";
import { CalAiPhoneNumberRepository } from "@calcom/lib/server/repository/calAiPhoneNumber";
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
import { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../types";
import type { TListCallsInputSchema } from "./listCalls.schema";
type ListCallsHandlerOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TListCallsInputSchema;
};
export const listCallsHandler = async ({ ctx, input }: ListCallsHandlerOptions) => {
const organizationId = ctx.user.organizationId ?? ctx.user.profiles?.[0]?.organizationId;
try {
const userMemberships = await MembershipRepository.findAllByUserId({
userId: ctx.user.id,
filters: {
accepted: true,
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
});
const adminTeamIds = userMemberships
.filter((m) => !organizationId || m.team?.parentId === organizationId)
.map((m) => m.teamId);
const isOrgOwner = organizationId
? userMemberships.some((m) => m.role === MembershipRole.OWNER && m.team?.parentId === organizationId)
: false;
const uniquePhoneNumbers = await CalAiPhoneNumberRepository.getAccessiblePhoneNumbers({
userId: ctx.user.id,
organizationId,
isOrgOwner,
adminTeamIds,
});
if (uniquePhoneNumbers.length === 0) {
logger.info("No phone numbers found for user", { userId: ctx.user.id, organizationId });
return {
calls: [],
totalCount: 0,
};
}
const aiService = createDefaultAIPhoneServiceProvider();
let startTimestamp: { lower_threshold?: number; upper_threshold?: number } | undefined;
if (input.filters?.startDate || input.filters?.endDate) {
startTimestamp = {};
if (input.filters.startDate) {
const parsedStartDate = Date.parse(input.filters.startDate);
if (!isFinite(parsedStartDate)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid startDate format",
});
}
startTimestamp.lower_threshold = parsedStartDate;
}
if (input.filters.endDate) {
const parsedEndDate = Date.parse(input.filters.endDate);
if (!isFinite(parsedEndDate)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid endDate format",
});
}
startTimestamp.upper_threshold = parsedEndDate;
}
if (
startTimestamp.lower_threshold &&
startTimestamp.upper_threshold &&
startTimestamp.lower_threshold > startTimestamp.upper_threshold
) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "startDate must be before or equal to endDate",
});
}
}
const calls = await aiService.listCalls({
limit: input.limit,
offset: input.offset,
filters: {
fromNumber: uniquePhoneNumbers,
...(startTimestamp && { startTimestamp }),
},
});
return {
calls,
totalCount: calls.length,
};
} catch (error) {
if (error instanceof TRPCError) {
throw error;
}
logger.error(`Failed to list calls for user ${ctx.user.id}:`, error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to retrieve call history",
});
}
};
@@ -0,0 +1,15 @@
import { z } from "zod";
export const ZListCallsInputSchema = z.object({
limit: z.number().min(1).max(1000).default(50),
offset: z.number().min(0).default(0),
filters: z
.object({
phoneNumberId: z.array(z.string()).optional(),
startDate: z.string().optional(),
endDate: z.string().optional(),
})
.optional(),
});
export type TListCallsInputSchema = z.infer<typeof ZListCallsInputSchema>;