Skip to content

Commit ded2884

Browse files
committed
feat(analytics): Implement comprehensive analytics dashboard with charts and metrics
- Add analytics API endpoints (overview, conversations, agents, costs, crm) - Add Chat Service internal endpoint for message listing - Install recharts library for data visualization - Create date range picker component with presets - Implement overview metrics component (KPI cards) - Add conversation chart (line chart) for trends - Add channel distribution component (pie chart) - Create agent performance table component - Add cost analysis component - Implement CRM insights component with bar chart - Build main analytics page integrating all components - Add analytics API client with React Query hooks
1 parent 0fbbef8 commit ded2884

54 files changed

Lines changed: 2247 additions & 117 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/deploy-voice-agent.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,5 @@ jobs:
5252
run: |
5353
python -c "import sys; sys.path.insert(0, 'src'); import main; import agent; import config; print('✅ All imports successful')"
5454
55+
56+

create-workflow-example.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,5 @@
113113

114114

115115

116+
117+

database/supabase/migrations/010_workflows.sql

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,3 +118,5 @@ CREATE POLICY "System can insert workflow executions"
118118

119119

120120

121+
122+

database/supabase/migrations/011_notifications.sql

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,5 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;
7070

7171

7272

73+
74+

database/supabase/migrations/012_add_agent_avatar_url.sql

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,5 @@ COMMENT ON COLUMN public.agent_configs.avatar_url IS 'URL to the agent profile p
1010

1111

1212

13+
14+
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
/**
2+
* Analytics Agents API
3+
* Returns agent performance metrics
4+
*/
5+
6+
import { NextRequest, NextResponse } from 'next/server'
7+
import { withAuth } from '@/lib/api/middleware'
8+
import { createClient } from '@/lib/supabase/server'
9+
import { logger } from '@/lib/utils/logger'
10+
11+
const CHAT_SERVICE_URL = process.env.CHAT_SERVICE_URL || 'http://localhost:4004'
12+
13+
export async function GET(request: NextRequest) {
14+
return withAuth(request, async (req, ctx) => {
15+
try {
16+
if (!ctx.companyId) {
17+
return NextResponse.json({ error: 'Company ID required' }, { status: 400 })
18+
}
19+
20+
const supabase = await createClient()
21+
const searchParams = req.nextUrl.searchParams
22+
const startDate = searchParams.get('startDate')
23+
const endDate = searchParams.get('endDate')
24+
25+
// Get agents from Supabase
26+
const { data: agents, error: agentsError } = await supabase
27+
.from('agent_configs')
28+
.select('id, name, enabled')
29+
.eq('company_id', ctx.companyId)
30+
.eq('enabled', true)
31+
32+
if (agentsError) {
33+
logger.warn('Failed to fetch agents', { error: agentsError })
34+
}
35+
36+
if (!agents || agents.length === 0) {
37+
return NextResponse.json({ agents: [] })
38+
}
39+
40+
// Get access token for Chat Service
41+
const {
42+
data: { session },
43+
} = await supabase.auth.getSession()
44+
45+
if (!session?.access_token) {
46+
return NextResponse.json({ error: 'No session token' }, { status: 401 })
47+
}
48+
49+
// Fetch conversations from Chat Service
50+
const conversationsResponse = await fetch(
51+
`${CHAT_SERVICE_URL}/api/conversations?limit=1000`,
52+
{
53+
headers: {
54+
Authorization: `Bearer ${session.access_token}`,
55+
},
56+
}
57+
)
58+
59+
if (!conversationsResponse.ok) {
60+
throw new Error('Failed to fetch conversations')
61+
}
62+
63+
const conversationsData = await conversationsResponse.json()
64+
let conversations = conversationsData.conversations || []
65+
66+
// Filter by date range if provided
67+
if (startDate || endDate) {
68+
conversations = conversations.filter((conv: { started_at: string }) => {
69+
const convDate = new Date(conv.started_at)
70+
if (startDate && convDate < new Date(startDate)) return false
71+
if (endDate && convDate > new Date(endDate)) return false
72+
return true
73+
})
74+
}
75+
76+
// Fetch messages for response time and satisfaction
77+
const messagesResponse = await fetch(
78+
`${CHAT_SERVICE_URL}/api/internal/messages/list?limit=1000`,
79+
{
80+
headers: {
81+
Authorization: `Bearer ${process.env.INTERNAL_SERVICE_TOKEN || ''}`,
82+
'Content-Type': 'application/json',
83+
},
84+
method: 'POST',
85+
body: JSON.stringify({
86+
companyId: ctx.companyId,
87+
limit: 1000,
88+
startDate,
89+
endDate,
90+
}),
91+
}
92+
)
93+
94+
const messages = messagesResponse.ok
95+
? (await messagesResponse.json()).messages || []
96+
: []
97+
98+
// Calculate metrics per agent
99+
const agentMetrics = agents.map((agent) => {
100+
const agentConversations = conversations.filter(
101+
(c: { agent_id: string }) => c.agent_id === agent.id
102+
)
103+
104+
const agentMessages = messages.filter(
105+
(m: { conversation_id: string; sender_type: string; ai_metadata?: { response_time_ms?: number }; metadata?: { sentiment?: { sentiment: string } } }) => {
106+
const conv = conversations.find((c: { _id: string; agent_id: string }) =>
107+
String(c._id) === m.conversation_id && c.agent_id === agent.id
108+
)
109+
return conv && m.sender_type === 'agent'
110+
}
111+
)
112+
113+
// Calculate average response time
114+
const messagesWithResponseTime = agentMessages.filter(
115+
(m: { ai_metadata?: { response_time_ms?: number } }) => m.ai_metadata?.response_time_ms
116+
)
117+
const avgResponseTime =
118+
messagesWithResponseTime.length > 0
119+
? Math.round(
120+
messagesWithResponseTime.reduce(
121+
(sum: number, m: { ai_metadata: { response_time_ms: number } }) =>
122+
sum + (m.ai_metadata.response_time_ms || 0),
123+
0
124+
) / messagesWithResponseTime.length
125+
)
126+
: 0
127+
128+
// Calculate satisfaction
129+
const messagesWithSentiment = agentMessages.filter(
130+
(m: { metadata?: { sentiment?: { sentiment: string } } }) =>
131+
m.metadata?.sentiment?.sentiment
132+
)
133+
const satisfaction =
134+
messagesWithSentiment.length > 0
135+
? Math.round(
136+
(messagesWithSentiment.filter(
137+
(m: { metadata: { sentiment: { sentiment: string } } }) =>
138+
m.metadata.sentiment.sentiment === 'positive'
139+
).length /
140+
messagesWithSentiment.length) *
141+
100
142+
)
143+
: 0
144+
145+
return {
146+
agentId: agent.id,
147+
agentName: agent.name || 'Unnamed Agent',
148+
conversationCount: agentConversations.length,
149+
avgResponseTime,
150+
satisfaction,
151+
}
152+
})
153+
154+
// Sort by conversation count (top performers first)
155+
agentMetrics.sort((a, b) => b.conversationCount - a.conversationCount)
156+
157+
return NextResponse.json({ agents: agentMetrics })
158+
} catch (error) {
159+
logger.error('Analytics agents error', { error })
160+
return NextResponse.json(
161+
{ error: 'Failed to fetch agent analytics' },
162+
{ status: 500 }
163+
)
164+
}
165+
}, { requireCompany: true })
166+
}
167+
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/**
2+
* Analytics Conversations API
3+
* Returns conversation analytics (timeline, by channel, avg duration)
4+
*/
5+
6+
import { NextRequest, NextResponse } from 'next/server'
7+
import { withAuth } from '@/lib/api/middleware'
8+
import { createClient } from '@/lib/supabase/server'
9+
import { logger } from '@/lib/utils/logger'
10+
11+
const CHAT_SERVICE_URL = process.env.CHAT_SERVICE_URL || 'http://localhost:4004'
12+
13+
export async function GET(request: NextRequest) {
14+
return withAuth(request, async (req, ctx) => {
15+
try {
16+
if (!ctx.companyId) {
17+
return NextResponse.json({ error: 'Company ID required' }, { status: 400 })
18+
}
19+
20+
const supabase = await createClient()
21+
const searchParams = req.nextUrl.searchParams
22+
const startDate = searchParams.get('startDate')
23+
const endDate = searchParams.get('endDate')
24+
const groupBy = searchParams.get('groupBy') || 'day'
25+
26+
// Get access token for Chat Service
27+
const {
28+
data: { session },
29+
} = await supabase.auth.getSession()
30+
31+
if (!session?.access_token) {
32+
return NextResponse.json({ error: 'No session token' }, { status: 401 })
33+
}
34+
35+
// Fetch conversations from Chat Service
36+
const convParams = new URLSearchParams()
37+
if (startDate) convParams.append('startDate', startDate)
38+
if (endDate) convParams.append('endDate', endDate)
39+
40+
const conversationsResponse = await fetch(
41+
`${CHAT_SERVICE_URL}/api/conversations?${convParams.toString()}&limit=1000`,
42+
{
43+
headers: {
44+
Authorization: `Bearer ${session.access_token}`,
45+
},
46+
}
47+
)
48+
49+
if (!conversationsResponse.ok) {
50+
throw new Error('Failed to fetch conversations')
51+
}
52+
53+
const conversationsData = await conversationsResponse.json()
54+
let conversations = conversationsData.conversations || []
55+
56+
// Filter by date range if provided
57+
if (startDate || endDate) {
58+
conversations = conversations.filter((conv: { started_at: string }) => {
59+
const convDate = new Date(conv.started_at)
60+
if (startDate && convDate < new Date(startDate)) return false
61+
if (endDate && convDate > new Date(endDate)) return false
62+
return true
63+
})
64+
}
65+
66+
// Group conversations by date
67+
const timelineMap = new Map<string, number>()
68+
const channelMap = new Map<string, number>()
69+
let totalDuration = 0
70+
let conversationsWithDuration = 0
71+
72+
conversations.forEach((conv: { started_at: string; ended_at?: string; channel: string }) => {
73+
// Timeline grouping
74+
const date = new Date(conv.started_at)
75+
let dateKey: string
76+
77+
if (groupBy === 'week') {
78+
const weekStart = new Date(date)
79+
weekStart.setDate(date.getDate() - date.getDay())
80+
dateKey = weekStart.toISOString().split('T')[0]
81+
} else if (groupBy === 'month') {
82+
dateKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
83+
} else {
84+
dateKey = date.toISOString().split('T')[0]
85+
}
86+
87+
timelineMap.set(dateKey, (timelineMap.get(dateKey) || 0) + 1)
88+
89+
// Channel grouping
90+
channelMap.set(conv.channel, (channelMap.get(conv.channel) || 0) + 1)
91+
92+
// Calculate duration
93+
if (conv.ended_at) {
94+
const duration = new Date(conv.ended_at).getTime() - new Date(conv.started_at).getTime()
95+
totalDuration += duration
96+
conversationsWithDuration++
97+
}
98+
})
99+
100+
// Convert timeline map to array
101+
const timeline = Array.from(timelineMap.entries())
102+
.map(([date, count]) => ({ date, count }))
103+
.sort((a, b) => a.date.localeCompare(b.date))
104+
105+
// Convert channel map to array
106+
const byChannel = Array.from(channelMap.entries()).map(([channel, count]) => ({
107+
channel,
108+
count,
109+
}))
110+
111+
// Calculate average duration in seconds
112+
const avgDuration = conversationsWithDuration > 0
113+
? Math.round(totalDuration / conversationsWithDuration / 1000)
114+
: 0
115+
116+
return NextResponse.json({
117+
timeline,
118+
byChannel,
119+
avgDuration,
120+
})
121+
} catch (error) {
122+
logger.error('Analytics conversations error', { error })
123+
return NextResponse.json(
124+
{ error: 'Failed to fetch conversation analytics' },
125+
{ status: 500 }
126+
)
127+
}
128+
}, { requireCompany: true })
129+
}
130+

0 commit comments

Comments
 (0)