BusyBee3333 4e6467ffb0 Add CRESync CRM application with Setup page
- 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>
2026-01-14 17:30:55 -05:00

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`;
}