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:
@@ -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.",
|
||||
|
||||
@@ -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>;
|
||||
Reference in New Issue
Block a user