- Build complete Next.js CRM for commercial real estate - Add authentication with JWT sessions and role-based access - Add GoHighLevel API integration for contacts, conversations, opportunities - Add AI-powered Control Center with tool calling - Add Setup page with onboarding checklist (/setup) - Add sidebar navigation with Setup menu item - Fix type errors in onboarding API, GHL services, and control center tools - Add Prisma schema with SQLite for local development - Add UI components with clay morphism design system Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
298 lines
11 KiB
TypeScript
298 lines
11 KiB
TypeScript
import { NextResponse } from 'next/server';
|
|
import { getSession } from '@/lib/auth';
|
|
import { createMCPClient } from '@/lib/control-center/mcp-client';
|
|
|
|
export interface Recommendation {
|
|
id: string;
|
|
type: 'follow_up' | 'stale_lead' | 'pipeline_stuck' | 'campaign_response' | 'no_response';
|
|
priority: 'high' | 'medium' | 'low';
|
|
title: string;
|
|
description: string;
|
|
actionLabel: string;
|
|
actionUrl: string;
|
|
contactId?: string;
|
|
contactName?: string;
|
|
daysOverdue?: number;
|
|
pipelineStage?: string;
|
|
}
|
|
|
|
interface RecommendationsResponse {
|
|
recommendations: Recommendation[];
|
|
generatedAt: string;
|
|
}
|
|
|
|
// Fallback recommendations shown when MCP isn't available or returns no data
|
|
function getFallbackRecommendations(): Recommendation[] {
|
|
return [
|
|
{
|
|
id: 'fallback-import-contacts',
|
|
type: 'follow_up',
|
|
priority: 'high',
|
|
title: 'Import your contacts',
|
|
description: 'Get started by importing your existing contacts from a CSV or CRM',
|
|
actionLabel: 'Import Now',
|
|
actionUrl: '/contacts',
|
|
},
|
|
{
|
|
id: 'fallback-configure-sms',
|
|
type: 'follow_up',
|
|
priority: 'medium',
|
|
title: 'Set up SMS messaging',
|
|
description: 'Configure two-way SMS to reach contacts via text',
|
|
actionLabel: 'Configure',
|
|
actionUrl: '/settings/sms',
|
|
},
|
|
{
|
|
id: 'fallback-create-pipeline',
|
|
type: 'pipeline_stuck',
|
|
priority: 'medium',
|
|
title: 'Create your first pipeline',
|
|
description: 'Track deals from lead to close with custom stages',
|
|
actionLabel: 'Create Pipeline',
|
|
actionUrl: '/opportunities',
|
|
},
|
|
{
|
|
id: 'fallback-explore-control-center',
|
|
type: 'campaign_response',
|
|
priority: 'low',
|
|
title: 'Explore AI Control Center',
|
|
description: 'Use AI to analyze your CRM data and get insights',
|
|
actionLabel: 'Explore',
|
|
actionUrl: '/control-center',
|
|
},
|
|
];
|
|
}
|
|
|
|
export async function GET() {
|
|
const session = await getSession();
|
|
if (!session) {
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
}
|
|
|
|
try {
|
|
const mcpClient = await createMCPClient();
|
|
|
|
if (!mcpClient) {
|
|
// Return fallback recommendations if MCP not configured
|
|
return NextResponse.json({
|
|
recommendations: getFallbackRecommendations(),
|
|
generatedAt: new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
const isHealthy = await mcpClient.healthCheck();
|
|
if (!isHealthy) {
|
|
return NextResponse.json({
|
|
recommendations: getFallbackRecommendations(),
|
|
generatedAt: new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
// Ensure connected for tool execution
|
|
await mcpClient.connect();
|
|
|
|
const recommendations: Recommendation[] = [];
|
|
|
|
// 1. Get conversations and check for ones needing follow-up
|
|
try {
|
|
const conversationsResult = await mcpClient.executeTool('search_conversations', {
|
|
limit: 50,
|
|
});
|
|
|
|
if (conversationsResult.success && conversationsResult.result) {
|
|
const data = conversationsResult.result as any;
|
|
const conversations = data?.conversations || data || [];
|
|
|
|
for (const conv of (Array.isArray(conversations) ? conversations : []).slice(0, 15)) {
|
|
// Check if last message was from us and no response
|
|
const lastMessageAt = conv.lastMessageDate || conv.dateUpdated || conv.updatedAt;
|
|
const lastDate = lastMessageAt ? new Date(lastMessageAt) : null;
|
|
const daysSinceMessage = lastDate
|
|
? Math.floor((Date.now() - lastDate.getTime()) / (1000 * 60 * 60 * 24))
|
|
: null;
|
|
|
|
const lastMessageDirection = conv.lastMessageDirection || conv.lastMessageType || '';
|
|
const isOutbound = lastMessageDirection === 'outbound' || lastMessageDirection === 'out';
|
|
|
|
if (daysSinceMessage && daysSinceMessage >= 7 && isOutbound) {
|
|
const contactName = conv.contactName || conv.fullName || conv.name || 'contact';
|
|
recommendations.push({
|
|
id: `no-response-${conv.id}`,
|
|
type: 'no_response',
|
|
priority: daysSinceMessage >= 14 ? 'high' : 'medium',
|
|
title: `Follow up with ${contactName}`,
|
|
description: `No response in ${daysSinceMessage} days since your last message`,
|
|
actionLabel: 'Send Follow-up',
|
|
actionUrl: `/conversations/${conv.id}`,
|
|
contactId: conv.contactId,
|
|
contactName: contactName,
|
|
daysOverdue: daysSinceMessage,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn('[Recommendations] Failed to fetch conversations:', e);
|
|
}
|
|
|
|
// 2. Get opportunities stuck in pipeline stages
|
|
try {
|
|
const oppsResult = await mcpClient.executeTool('search_opportunities', {
|
|
status: 'open',
|
|
limit: 30,
|
|
});
|
|
|
|
if (oppsResult.success && oppsResult.result) {
|
|
const data = oppsResult.result as any;
|
|
const opportunities = data?.opportunities || data || [];
|
|
|
|
for (const opp of (Array.isArray(opportunities) ? opportunities : []).slice(0, 20)) {
|
|
const stageEnteredAt = opp.lastStageChangeAt || opp.stageEnteredAt || opp.updatedAt || opp.dateUpdated;
|
|
const stageDate = stageEnteredAt ? new Date(stageEnteredAt) : null;
|
|
const daysInStage = stageDate
|
|
? Math.floor((Date.now() - stageDate.getTime()) / (1000 * 60 * 60 * 24))
|
|
: null;
|
|
|
|
// Flag opportunities stuck in same stage for 14+ days
|
|
if (daysInStage && daysInStage >= 14) {
|
|
const oppName = opp.name || opp.title || 'Opportunity';
|
|
const stageName = opp.pipelineStage || opp.stageName || opp.stage || 'current stage';
|
|
recommendations.push({
|
|
id: `pipeline-stuck-${opp.id}`,
|
|
type: 'pipeline_stuck',
|
|
priority: daysInStage >= 30 ? 'high' : 'medium',
|
|
title: `Move "${oppName}" forward`,
|
|
description: `Stuck in "${stageName}" for ${daysInStage} days`,
|
|
actionLabel: 'Review Deal',
|
|
actionUrl: `/opportunities/${opp.id}`,
|
|
contactId: opp.contactId,
|
|
contactName: opp.contactName || opp.contact?.name,
|
|
daysOverdue: daysInStage,
|
|
pipelineStage: stageName,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn('[Recommendations] Failed to fetch opportunities:', e);
|
|
}
|
|
|
|
// 3. Get email campaigns (optional - may not have data)
|
|
try {
|
|
const campaignsResult = await mcpClient.executeTool('get_email_campaigns', {
|
|
limit: 10,
|
|
});
|
|
|
|
if (campaignsResult.success && campaignsResult.result) {
|
|
const data = campaignsResult.result as any;
|
|
const campaigns = data?.campaigns || data || [];
|
|
|
|
for (const campaign of (Array.isArray(campaigns) ? campaigns : []).slice(0, 5)) {
|
|
// Check for campaigns with good open/click rates that might need follow-up
|
|
const opens = campaign.opens || campaign.openCount || 0;
|
|
const clicks = campaign.clicks || campaign.clickCount || 0;
|
|
|
|
if (opens > 0 || clicks > 0) {
|
|
recommendations.push({
|
|
id: `campaign-engagement-${campaign.id}`,
|
|
type: 'campaign_response',
|
|
priority: clicks > 5 ? 'high' : 'medium',
|
|
title: `"${campaign.name || 'Campaign'}" has engagement`,
|
|
description: `${opens} opens, ${clicks} clicks - follow up with engaged leads`,
|
|
actionLabel: 'View Campaign',
|
|
actionUrl: `/campaigns/${campaign.id}`,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn('[Recommendations] Failed to fetch campaigns:', e);
|
|
}
|
|
|
|
// 4. Get contacts and find ones needing outreach
|
|
try {
|
|
const contactsResult = await mcpClient.executeTool('search_contacts', {
|
|
limit: 30,
|
|
});
|
|
|
|
if (contactsResult.success && contactsResult.result) {
|
|
const data = contactsResult.result as any;
|
|
const contacts = data?.contacts || data || [];
|
|
|
|
for (const contact of (Array.isArray(contacts) ? contacts : []).slice(0, 20)) {
|
|
const dateAdded = contact.dateAdded || contact.createdAt || contact.dateCreated;
|
|
const addedDate = dateAdded ? new Date(dateAdded) : null;
|
|
const daysSinceAdded = addedDate
|
|
? Math.floor((Date.now() - addedDate.getTime()) / (1000 * 60 * 60 * 24))
|
|
: null;
|
|
|
|
// Check if contact was added in last 14 days and has no conversation
|
|
const hasConversation = contact.lastMessageDate || contact.lastConversationDate;
|
|
|
|
if (daysSinceAdded !== null && daysSinceAdded <= 14 && !hasConversation) {
|
|
const firstName = contact.firstName || contact.name?.split(' ')[0] || '';
|
|
const lastName = contact.lastName || '';
|
|
const displayName = firstName || contact.email || 'new contact';
|
|
|
|
recommendations.push({
|
|
id: `new-contact-${contact.id}`,
|
|
type: 'follow_up',
|
|
priority: daysSinceAdded <= 3 ? 'high' : 'medium',
|
|
title: `Reach out to ${displayName}`,
|
|
description: `Added ${getRelativeTime(dateAdded)} - no outreach yet`,
|
|
actionLabel: 'Start Conversation',
|
|
actionUrl: `/contacts/${contact.id}`,
|
|
contactId: contact.id,
|
|
contactName: firstName ? `${firstName} ${lastName}`.trim() : contact.email,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn('[Recommendations] Failed to fetch contacts:', e);
|
|
}
|
|
|
|
// Sort by priority and limit to top 4
|
|
const priorityOrder = { high: 0, medium: 1, low: 2 };
|
|
recommendations.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]);
|
|
const topRecommendations = recommendations.slice(0, 4);
|
|
|
|
// If no recommendations from MCP data, return fallback recommendations
|
|
const finalRecommendations = topRecommendations.length > 0
|
|
? topRecommendations
|
|
: getFallbackRecommendations();
|
|
|
|
const response: RecommendationsResponse = {
|
|
recommendations: finalRecommendations,
|
|
generatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
return NextResponse.json(response);
|
|
} catch (error) {
|
|
console.error('[Recommendations] Error:', error);
|
|
// Return fallback recommendations on error instead of failing
|
|
return NextResponse.json({
|
|
recommendations: getFallbackRecommendations(),
|
|
generatedAt: new Date().toISOString(),
|
|
});
|
|
}
|
|
}
|
|
|
|
function getRelativeTime(dateString: string | number | undefined): string {
|
|
if (!dateString) return 'recently';
|
|
|
|
const date = typeof dateString === 'number'
|
|
? new Date(dateString * 1000)
|
|
: new Date(dateString);
|
|
|
|
const now = new Date();
|
|
const diffMs = now.getTime() - date.getTime();
|
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
|
|
if (diffDays === 0) return 'today';
|
|
if (diffDays === 1) return 'yesterday';
|
|
if (diffDays < 7) return `${diffDays} days ago`;
|
|
if (diffDays < 14) return 'last week';
|
|
return `${Math.floor(diffDays / 7)} weeks ago`;
|
|
}
|