diff --git a/apps/web/app/(use-page-wrapper)/insights/call-history/page.tsx b/apps/web/app/(use-page-wrapper)/insights/call-history/page.tsx new file mode 100644 index 00000000000000..7335b1a5685e0c --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/insights/call-history/page.tsx @@ -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 ; +} diff --git a/apps/web/modules/insights/insights-call-history-view.tsx b/apps/web/modules/insights/insights-call-history-view.tsx new file mode 100644 index 00000000000000..f9bb12e90223b4 --- /dev/null +++ b/apps/web/modules/insights/insights-call-history-view.tsx @@ -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 ( + + + + ); +} + +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[]>( + () => [ + { + id: "time", + accessorKey: "time", + header: t("time_header"), + size: 150, + cell: ({ row }) => { + const date = new Date(row.original.time); + return ( +
+
{date.toLocaleDateString()}
+
{date.toLocaleTimeString()}
+
+ ); + }, + }, + { + 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 {`${minutes}:${remainingSeconds.toString().padStart(2, "0")}`}; + }, + }, + { + id: "channelType", + accessorKey: "channelType", + header: t("channel_type"), + size: 160, + meta: { + filter: { type: ColumnFilterType.MULTI_SELECT }, + }, + cell: ({ row }) => {row.original.channelType}, + }, + { + id: "sessionId", + accessorKey: "sessionId", + header: t("session_id"), + size: 210, + cell: ({ row }) => {row.original.sessionId}, + }, + { + id: "endReason", + accessorKey: "endReason", + header: t("end_reason"), + size: 180, + cell: ({ row }) => {row.original.endReason}, + }, + { + 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 {status}; + }, + }, + { + 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 {sentiment}; + }, + }, + { + 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 {created ? t("successful") : t("unsuccessful")}; + }, + }, + { + id: "inVoicemail", + accessorKey: "inVoicemail", + header: t("voicemail"), + size: 150, + cell: ({ row }) => { + const inVoicemail = row.original.inVoicemail; + const variant = inVoicemail ? "blue" : "gray"; + return {inVoicemail ? t("yes") : t("no")}; + }, + }, + ], + [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 ( + <> + + 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={ + <> + + + {/* */} + + } + ToolbarRight={ + <> + + + } + /> + + {state.callDetailsSheet.showModal && } + + ); +} + +export default function InsightsCallHistoryPage() { + const { data: org } = trpc.viewer.organizations.listCurrent.useQuery(); + + return ; +} diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 08b937b7f117e0..bbac1395d72449 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2466,7 +2466,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", @@ -3628,6 +3636,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", @@ -3655,6 +3666,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", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/packages/features/calAIPhone/index.ts b/packages/features/calAIPhone/index.ts index d438998a81ee64..3c7f95092feb87 100644 --- a/packages/features/calAIPhone/index.ts +++ b/packages/features/calAIPhone/index.ts @@ -45,6 +45,8 @@ export type { AIConfigurationSetup, AIConfigurationDeletion, DeletionResult, + RetellCallListParams, + RetellCallListResponse, } from "./providers/retellAI"; // Legacy exports for backward compatibility diff --git a/packages/features/calAIPhone/interfaces/AIPhoneService.interface.ts b/packages/features/calAIPhone/interfaces/AIPhoneService.interface.ts index fb0bc3ae60ff3f..6a703503adee76 100644 --- a/packages/features/calAIPhone/interfaces/AIPhoneService.interface.ts +++ b/packages/features/calAIPhone/interfaces/AIPhoneService.interface.ts @@ -89,6 +89,13 @@ export type AIPhoneServiceAgentWithDetails< T extends AIPhoneServiceProviderType = AIPhoneServiceProviderType > = AIPhoneServiceProviderTypeMap[T]["AgentWithDetails"]; +export type AIPhoneServiceListCallsParams = + 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; + + /** + * List calls with optional filters + */ + listCalls(params: { + limit?: number; + offset?: number; + filters: { + fromNumber: string[]; + toNumber?: string[]; + startTimestamp?: { lower_threshold?: number; upper_threshold?: number }; + }; + }): Promise>; } /** diff --git a/packages/features/calAIPhone/providers/retellAI/RetellAIPhoneServiceProvider.ts b/packages/features/calAIPhone/providers/retellAI/RetellAIPhoneServiceProvider.ts index 00784dd7e12be0..5e280cff82c090 100644 --- a/packages/features/calAIPhone/providers/retellAI/RetellAIPhoneServiceProvider.ts +++ b/packages/features/calAIPhone/providers/retellAI/RetellAIPhoneServiceProvider.ts @@ -290,4 +290,16 @@ export class RetellAIPhoneServiceProvider async removeToolsForEventTypes(agentId: string, eventTypeIds: number[]): Promise { 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); + } } diff --git a/packages/features/calAIPhone/providers/retellAI/RetellAIService.ts b/packages/features/calAIPhone/providers/retellAI/RetellAIService.ts index 8390436ec0fbf6..850e59c8f6eb19 100644 --- a/packages/features/calAIPhone/providers/retellAI/RetellAIService.ts +++ b/packages/features/calAIPhone/providers/retellAI/RetellAIService.ts @@ -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); + } } diff --git a/packages/features/calAIPhone/providers/retellAI/RetellSDKClient.ts b/packages/features/calAIPhone/providers/retellAI/RetellSDKClient.ts index be1526051eb30f..89b3234e98b7ac 100644 --- a/packages/features/calAIPhone/providers/retellAI/RetellSDKClient.ts +++ b/packages/features/calAIPhone/providers/retellAI/RetellSDKClient.ts @@ -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 { + 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({ diff --git a/packages/features/calAIPhone/providers/retellAI/index.ts b/packages/features/calAIPhone/providers/retellAI/index.ts index f7bb7acacc8013..139280b91ce060 100644 --- a/packages/features/calAIPhone/providers/retellAI/index.ts +++ b/packages/features/calAIPhone/providers/retellAI/index.ts @@ -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 ===== diff --git a/packages/features/calAIPhone/providers/retellAI/services/CallService.ts b/packages/features/calAIPhone/providers/retellAI/services/CallService.ts index e6f01183adabc1..c0b4cebd2b20d1 100644 --- a/packages/features/calAIPhone/providers/retellAI/services/CallService.ts +++ b/packages/features/calAIPhone/providers/retellAI/services/CallService.ts @@ -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 { + 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", + }); + } + } } diff --git a/packages/features/calAIPhone/providers/retellAI/types.ts b/packages/features/calAIPhone/providers/retellAI/types.ts index 9cc07ccd30116b..4ff785c4b9f4d1 100644 --- a/packages/features/calAIPhone/providers/retellAI/types.ts +++ b/packages/features/calAIPhone/providers/retellAI/types.ts @@ -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; + + listCalls(params: RetellCallListParams): Promise; + createWebCall( data: CreateWebCallParams ): Promise<{ call_id: string; access_token: string; agent_id: string }>; diff --git a/packages/features/ee/workflows/components/CallDetailsSheet.tsx b/packages/features/ee/workflows/components/CallDetailsSheet.tsx new file mode 100644 index 00000000000000..374653356024ae --- /dev/null +++ b/packages/features/ee/workflows/components/CallDetailsSheet.tsx @@ -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; +} + +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 ( + { + dispatch({ type: "CLOSE_MODAL" }); + }}> + + +
+
+

{t("call_details")}

+

+ {t("call_id")}: {selectedCall.call_id}{" "} + + {selectedCall.call_status} + +

+
+
+
+ + + {/* Call Information */} +
+

{t("call_information")}

+
+
+

{t("start_time")}

+

+ {selectedCall.start_timestamp ? formatTimestamp(selectedCall.start_timestamp) : t("unknown")} +

+
+
+

{t("duration")}

+

{selectedCall.duration_ms ? formatDuration(selectedCall.duration_ms) : t("unknown")}

+
+
+

{t("from")}

+

{"from_number" in selectedCall ? selectedCall.from_number || t("unknown") : t("unknown")}

+
+
+

{t("to")}

+

{"to_number" in selectedCall ? selectedCall.to_number || t("unknown") : t("unknown")}

+
+
+

{t("sentiment")}

+ + {selectedCall.call_analysis?.user_sentiment || "Unknown"} + +
+
+

{t("disconnect_reason")}

+

+ {selectedCall.disconnection_reason?.replace(/_/g, " ")} +

+
+
+
+ + {/* Call Summary */} + {selectedCall.call_analysis?.call_summary && ( +
+

{t("call_summary")}

+

{selectedCall.call_analysis.call_summary}

+
+ )} + + {/* Recording Section */} +
+

{t("recording")}

+
+ + +
+
+ + {/* Transcription */} +
+

{t("transcription")}

+
+ {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 ( +
+
+ + + {isAgent ? t("agent") : t("user")} + +
+

{content}

+
+ ); + }) + ) : ( +

{t("no_transcript_available")}

+ )} +
+
+ + {/* Event Details */} + {selectedCall.retell_llm_dynamic_variables && ( +
+

{t("event_details")}

+
+
+                  {JSON.stringify(selectedCall.retell_llm_dynamic_variables, null, 2)}
+                
+
+
+ )} +
+ + + + + + +
+
+ ); +} diff --git a/packages/features/ee/workflows/components/types.ts b/packages/features/ee/workflows/components/types.ts new file mode 100644 index 00000000000000..d523ef1ddb9b55 --- /dev/null +++ b/packages/features/ee/workflows/components/types.ts @@ -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"; + }; \ No newline at end of file diff --git a/packages/features/ee/workflows/pages/call-history.tsx b/packages/features/ee/workflows/pages/call-history.tsx new file mode 100644 index 00000000000000..108a0402a5ee75 --- /dev/null +++ b/packages/features/ee/workflows/pages/call-history.tsx @@ -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 ( + + + + ); +} + +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[]>( + () => [ + { + id: "time", + accessorKey: "time", + header: t("time_header"), + size: 150, + cell: ({ row }) => { + const date = new Date(row.original.time); + return ( +
+
{date.toLocaleDateString()}
+
{date.toLocaleTimeString()}
+
+ ); + }, + }, + { + 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 {`${minutes}:${remainingSeconds.toString().padStart(2, "0")}`}; + }, + }, + { + id: "channelType", + accessorKey: "channelType", + header: t("channel_type"), + size: 160, + meta: { + filter: { type: ColumnFilterType.MULTI_SELECT }, + }, + cell: ({ row }) => {row.original.channelType}, + }, + { + id: "sessionId", + accessorKey: "sessionId", + header: t("session_id"), + size: 210, + cell: ({ row }) => {row.original.sessionId}, + }, + { + 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 {status}; + }, + }, + { + 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 {sentiment}; + }, + }, + { + 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 {created ? t("successful") : t("unsuccessful")}; + }, + }, + { + id: "inVoicemail", + accessorKey: "inVoicemail", + header: t("voicemail"), + size: 150, + cell: ({ row }) => { + const inVoicemail = row.original.inVoicemail; + const variant = inVoicemail ? "blue" : "gray"; + return {inVoicemail ? t("yes") : t("no")}; + }, + }, + ], + [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 ( + <> + + 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={ + <> + + + + + } + ToolbarRight={ + <> + + + } + /> + + {state.callDetailsSheet.showModal && } + + ); +} + +function CallHistoryPage() { + const { data: org } = trpc.viewer.organizations.listCurrent.useQuery(); + + return ; +} + +export default CallHistoryPage; diff --git a/packages/features/shell/navigation/Navigation.tsx b/packages/features/shell/navigation/Navigation.tsx index 69a572d434db6a..08fd2490e8ae69 100644 --- a/packages/features/shell/navigation/Navigation.tsx +++ b/packages/features/shell/navigation/Navigation.tsx @@ -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, + }, ], }, ]; diff --git a/packages/lib/server/repository/calAiPhoneNumber.ts b/packages/lib/server/repository/calAiPhoneNumber.ts new file mode 100644 index 00000000000000..8136338bd5c557 --- /dev/null +++ b/packages/lib/server/repository/calAiPhoneNumber.ts @@ -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 { + 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)); + } +} diff --git a/packages/lib/server/repository/membership.ts b/packages/lib/server/repository/membership.ts index cfa60ae3f37264..6063f141367942 100644 --- a/packages/lib/server/repository/membership.ts +++ b/packages/lib/server/repository/membership.ts @@ -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: { diff --git a/packages/trpc/server/routers/viewer/aiVoiceAgent/_router.ts b/packages/trpc/server/routers/viewer/aiVoiceAgent/_router.ts index e1597268728323..f5fb87327624c4 100644 --- a/packages/trpc/server/routers/viewer/aiVoiceAgent/_router.ts +++ b/packages/trpc/server/routers/viewer/aiVoiceAgent/_router.ts @@ -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"); diff --git a/packages/trpc/server/routers/viewer/aiVoiceAgent/listCalls.handler.ts b/packages/trpc/server/routers/viewer/aiVoiceAgent/listCalls.handler.ts new file mode 100644 index 00000000000000..305ca308707285 --- /dev/null +++ b/packages/trpc/server/routers/viewer/aiVoiceAgent/listCalls.handler.ts @@ -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; + }; + 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", + }); + } +}; diff --git a/packages/trpc/server/routers/viewer/aiVoiceAgent/listCalls.schema.ts b/packages/trpc/server/routers/viewer/aiVoiceAgent/listCalls.schema.ts new file mode 100644 index 00000000000000..536d52af841113 --- /dev/null +++ b/packages/trpc/server/routers/viewer/aiVoiceAgent/listCalls.schema.ts @@ -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; \ No newline at end of file