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' import { logServer } from "@/lib/server-logger"; 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(), site: z.string().optional(), custom: z.boolean().optional() })), strategies: z.array(z.string()), maxResults: z.number().default(50) minAgeDays: z.number().min(0).max(365).optional(), maxAgeDays: z.number().min(0).max(365).optional() }) }) export async function POST(request: NextRequest) { let jobId: string | undefined try { const requestId = request.headers.get("x-request-id") ?? undefined; 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 const ageFilters = { minAgeDays: config.minAgeDays, maxAgeDays: config.maxAgeDays, } if (!process.env.SERPER_API_KEY) { const errorMessage = "SERPER_API_KEY is not configured. Add it to your environment to run searches." await logServer({ level: "warn", message: "Serper API key missing", labels: ["api", "opportunities", "config", "warn"], payload: { projectId }, requestId, source: "api/opportunities", }); 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 await logServer({ level: "info", message: "Starting opportunity search", labels: ["api", "opportunities", "start"], payload: { projectId, productName: analysis.productName, platforms: config.platforms.filter((p) => p.enabled).map((p) => p.name), strategies: config.strategies, filters: ageFilters, }, requestId, source: "api/opportunities", }); // Generate queries await logServer({ level: "info", message: "Generating search queries", labels: ["api", "opportunities", "queries"], payload: { projectId }, requestId, source: "api/opportunities", }); const enforcedConfig: SearchConfig = { ...(config as SearchConfig), maxResults: Math.min((config as SearchConfig).maxResults || 50, 50), } const queries = generateSearchQueries(analysis as EnhancedProductAnalysis, enforcedConfig) await logServer({ level: "info", message: "Generated search queries", labels: ["api", "opportunities", "queries"], payload: { projectId, count: queries.length }, requestId, source: "api/opportunities", }); if (jobId) { await fetchMutation( api.searchJobs.update, { jobId: jobId as any, status: "running", progress: 40 }, { token } ); } // Execute searches await logServer({ level: "info", message: "Executing searches", labels: ["api", "opportunities", "search"], payload: { projectId, queryCount: queries.length }, requestId, source: "api/opportunities", }); const searchResults = await executeSearches(queries, ageFilters) await logServer({ level: "info", message: "Searches complete", labels: ["api", "opportunities", "search"], payload: { projectId, rawResults: searchResults.length }, requestId, source: "api/opportunities", }); if (jobId) { await fetchMutation( api.searchJobs.update, { jobId: jobId as any, status: "running", progress: 70 }, { token } ); } const resultUrls = Array.from( new Set(searchResults.map((result) => result.url).filter(Boolean)) ) const existingUrls = await fetchQuery( api.seenUrls.listExisting, { projectId: projectId as any, urls: resultUrls }, { token } ) const existingSet = new Set(existingUrls) const newUrls = resultUrls.filter((url) => !existingSet.has(url)) const filteredResults = searchResults.filter((result) => !existingSet.has(result.url)) // Score and rank await logServer({ level: "info", message: "Scoring opportunities", labels: ["api", "opportunities", "score"], payload: { projectId, candidateResults: filteredResults.length }, requestId, source: "api/opportunities", }); const opportunities = scoreOpportunities(filteredResults, analysis as EnhancedProductAnalysis) await logServer({ level: "info", message: "Opportunities scored", labels: ["api", "opportunities", "score"], payload: { projectId, scored: opportunities.length }, requestId, source: "api/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 } ); } if (newUrls.length > 0) { await fetchMutation( api.seenUrls.markSeenBatch, { projectId: projectId as any, urls: newUrls, source: "search" }, { token } ); } return NextResponse.json({ success: true, data: { opportunities: opportunities.slice(0, 50), stats: { queriesGenerated: queries.length, rawResults: filteredResults.length, opportunitiesFound: opportunities.length }, 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" await logServer({ level: "error", message: "Opportunity search error", labels: ["api", "opportunities", "error"], payload: { message: errorMessage }, requestId: request.headers.get("x-request-id") ?? undefined, source: "api/opportunities", }); 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' } ] }) }