import { NextRequest, NextResponse } from 'next/server' import { convexAuthNextjsToken, isAuthenticatedNextjs } from "@convex-dev/auth/nextjs/server"; import { fetchMutation, fetchQuery } from "convex/nextjs"; import { api } from "@/convex/_generated/api"; import { z } from 'zod' import { generateSearchQueries, getDefaultPlatforms } from '@/lib/query-generator' import { executeSearches, scoreOpportunities } from '@/lib/search-executor' import type { EnhancedProductAnalysis, SearchConfig, PlatformConfig } from '@/lib/types' const searchSchema = z.object({ projectId: z.string(), jobId: z.optional(z.string()), config: z.object({ platforms: z.array(z.object({ id: z.string(), name: z.string(), icon: z.string().optional(), enabled: z.boolean(), searchTemplate: z.string().optional(), rateLimit: z.number() })), strategies: z.array(z.string()), maxResults: z.number().default(50) }) }) export async function POST(request: NextRequest) { let jobId: string | undefined try { if (!(await isAuthenticatedNextjs())) { const redirectUrl = new URL("/auth", request.url); const referer = request.headers.get("referer"); const nextPath = referer ? new URL(referer).pathname + new URL(referer).search : "/"; redirectUrl.searchParams.set("next", nextPath); return NextResponse.redirect(redirectUrl); } const body = await request.json() const parsed = searchSchema.parse(body) const { projectId, config } = parsed jobId = parsed.jobId if (!process.env.SERPER_API_KEY) { const errorMessage = "SERPER_API_KEY is not configured. Add it to your environment to run searches." if (jobId) { await fetchMutation( api.searchJobs.update, { jobId: jobId as any, status: "failed", error: errorMessage }, { token: await convexAuthNextjsToken() } ) } return NextResponse.json({ error: errorMessage }, { status: 400 }) } const token = await convexAuthNextjsToken(); if (jobId) { await fetchMutation( api.searchJobs.update, { jobId: jobId as any, status: "running", progress: 10 }, { token } ); } const searchContext = await fetchQuery( api.projects.getSearchContext, { projectId: projectId as any }, { token } ); if (!searchContext.context) { if (jobId) { await fetchMutation( api.searchJobs.update, { jobId: jobId as any, status: "failed", error: "No analysis available." }, { token } ); } return NextResponse.json( { error: 'No analysis available for selected sources.' }, { status: 400 } ); } const analysis = searchContext.context as EnhancedProductAnalysis console.log('🔍 Starting opportunity search...') console.log(` Product: ${analysis.productName}`) console.log(` Platforms: ${config.platforms.filter(p => p.enabled).map(p => p.name).join(', ')}`) console.log(` Strategies: ${config.strategies.join(', ')}`) // Generate queries console.log(' Generating search queries...') const enforcedConfig: SearchConfig = { ...(config as SearchConfig), maxResults: Math.min((config as SearchConfig).maxResults || 50, 50), } const queries = generateSearchQueries(analysis as EnhancedProductAnalysis, enforcedConfig) console.log(` ✓ Generated ${queries.length} queries`) if (jobId) { await fetchMutation( api.searchJobs.update, { jobId: jobId as any, status: "running", progress: 40 }, { token } ); } // Execute searches console.log(' Executing searches...') const searchResults = await executeSearches(queries) console.log(` ✓ Found ${searchResults.length} raw results`) if (jobId) { await fetchMutation( api.searchJobs.update, { jobId: jobId as any, status: "running", progress: 70 }, { token } ); } // Score and rank console.log(' Scoring opportunities...') const opportunities = scoreOpportunities(searchResults, analysis as EnhancedProductAnalysis) console.log(` ✓ Scored ${opportunities.length} opportunities`) if (jobId) { await fetchMutation( api.searchJobs.update, { jobId: jobId as any, status: "running", progress: 90 }, { token } ); } if (jobId) { await fetchMutation( api.searchJobs.update, { jobId: jobId as any, status: "completed", progress: 100 }, { token } ); } return NextResponse.json({ success: true, data: { opportunities: opportunities.slice(0, 50), stats: { queriesGenerated: queries.length, rawResults: searchResults.length, opportunitiesFound: opportunities.length, highRelevance: opportunities.filter(o => o.relevanceScore >= 0.7).length, averageScore: opportunities.length > 0 ? opportunities.reduce((a, o) => a + o.relevanceScore, 0) / opportunities.length : 0 }, queries: queries.map(q => ({ query: q.query, platform: q.platform, strategy: q.strategy, priority: q.priority })), missingSources: searchContext.missingSources ?? [] } }) } catch (error: any) { const errorMessage = error instanceof Error ? error.message : typeof error === "string" ? error : "Search failed" console.error("❌ Opportunity search error:", errorMessage) if (jobId) { try { const token = await convexAuthNextjsToken(); await fetchMutation( api.searchJobs.update, { jobId: jobId as any, status: "failed", error: errorMessage }, { token } ); } catch { // Best-effort job update only. } } if (error?.name === 'ZodError') { return NextResponse.json( { error: 'Invalid request format', details: error?.errors }, { status: 400 } ) } return NextResponse.json( { error: errorMessage || 'Failed to search for opportunities' }, { status: 500 } ) } } // Get default configuration export async function GET(request: NextRequest) { if (!(await isAuthenticatedNextjs())) { const redirectUrl = new URL("/auth", request.url); const referer = request.headers.get("referer"); const nextPath = referer ? new URL(referer).pathname + new URL(referer).search : "/"; redirectUrl.searchParams.set("next", nextPath); return NextResponse.redirect(redirectUrl); } const defaultPlatforms = getDefaultPlatforms() return NextResponse.json({ platforms: Object.entries(defaultPlatforms).map(([id, config]) => ({ id, ...config })), strategies: [ { id: 'direct-keywords', name: 'Direct Keywords', description: 'Search for people looking for your product category' }, { id: 'problem-pain', name: 'Problem/Pain', description: 'Find people experiencing problems you solve' }, { id: 'competitor-alternative', name: 'Competitor Alternatives', description: 'People looking to switch from competitors' }, { id: 'how-to', name: 'How-To/Tutorials', description: 'People learning about solutions' }, { id: 'emotional-frustrated', name: 'Frustration Posts', description: 'Emotional posts about pain points' }, { id: 'comparison', name: 'Comparisons', description: '"X vs Y" comparison posts' }, { id: 'recommendation', name: 'Recommendations', description: '"What do you use" recommendation requests' } ] }) }