Files
SanatiLeads/app/api/opportunities/route.ts
2026-02-04 14:29:42 +00:00

318 lines
10 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'
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' }
]
})
}