225 lines
7.5 KiB
TypeScript
225 lines
7.5 KiB
TypeScript
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' }
|
|
]
|
|
})
|
|
}
|