import type { Pool } from 'pg'; import { getEnv } from '../config/env.js'; import { buildBusinessPayload, collectPlaces, geocodeLocation } from './google-places.js'; import { completeSearchJob, createSearchJob, createSearchJobForCoordinates, failSearchJob, updateSearchJobCenter, upsertBusiness, upsertSearchJobResult } from './repository.js'; import type { RunSearchInput, RunSearchResult } from './types.js'; function buildMatchedKeywords(keywords?: string) { return keywords ? keywords .split(',') .map((keyword) => keyword.trim()) .filter(Boolean) : []; } async function executeSearchJobAtCoordinates( db: Pool, input: { jobId: string; userId: string; lat: number; lng: number; radiusKm: number; businessType: string; keywords?: string; queryContextTerms?: string[]; }, ) { const env = getEnv(); if (!env.GOOGLE_MAPS_SERVER_KEY) { throw new Error('GOOGLE_MAPS_SERVER_KEY is required for running research.'); } const places = await collectPlaces({ apiKey: env.GOOGLE_MAPS_SERVER_KEY, textQuery: [input.businessType, input.keywords, ...(input.queryContextTerms ?? [])].filter(Boolean).join(' '), lat: input.lat, lng: input.lng, radiusKm: input.radiusKm, }); const matchedKeywords = buildMatchedKeywords(input.keywords); const capturedAt = new Date().toISOString(); let totalResults = 0; for (const [index, place] of places.entries()) { if (!place.id || !place.displayName?.text) { continue; } const businessId = await upsertBusiness(db, input.userId, buildBusinessPayload(place, input.businessType)); await upsertSearchJobResult(db, { userId: input.userId, searchJobId: input.jobId, businessId, matchedKeywords: matchedKeywords.length > 0 ? matchedKeywords : null, rank: index + 1, capturedAt, }); totalResults += 1; } return completeSearchJob(db, input.jobId, totalResults); } export async function runSearchForUser(db: Pool, userId: string, payload: RunSearchInput): Promise { const hasProvidedCoordinates = typeof payload.lat === 'number' && typeof payload.lng === 'number'; const job = hasProvidedCoordinates ? await createSearchJobForCoordinates(db, userId, { name: payload.name || `${payload.businessType} in ${payload.location}`, city: payload.location, address: payload.location, radiusKm: payload.radiusKm, businessType: payload.businessType, keywords: payload.keywords, lat: payload.lat!, lng: payload.lng!, }) : await createSearchJob(db, userId, payload); const jobId = job.id; try { const env = getEnv(); if (!env.GOOGLE_MAPS_SERVER_KEY) { throw new Error('GOOGLE_MAPS_SERVER_KEY is required for running research.'); } const resolvedCenter = hasProvidedCoordinates ? { lat: payload.lat!, lng: payload.lng! } : await geocodeLocation(payload.location, env.GOOGLE_MAPS_SERVER_KEY); if (!hasProvidedCoordinates) { await updateSearchJobCenter(db, jobId, resolvedCenter.lat, resolvedCenter.lng); } const completedJob = await executeSearchJobAtCoordinates(db, { jobId, userId, lat: resolvedCenter.lat, lng: resolvedCenter.lng, radiusKm: payload.radiusKm, businessType: payload.businessType, keywords: payload.keywords, }); return { job: completedJob, totalResults: completedJob.totalResults, }; } catch (error) { await failSearchJob(db, jobId); throw error; } } export async function runSearchForPostalArea(db: Pool, userId: string, input: { name: string; city?: string | null; address?: string | null; postalCode?: string | null; countryCode?: string | null; radiusKm: number; businessType: string; keywords?: string; lat: number; lng: number; deepResearchBatchId: string; postalAreaId: string; queryContextTerms?: string[]; }): Promise { const job = await createSearchJobForCoordinates(db, userId, { name: input.name, city: input.city, address: input.address, postalCode: input.postalCode, countryCode: input.countryCode, radiusKm: input.radiusKm, businessType: input.businessType, keywords: input.keywords, lat: input.lat, lng: input.lng, deepResearchBatchId: input.deepResearchBatchId, postalAreaId: input.postalAreaId, }); try { const completedJob = await executeSearchJobAtCoordinates(db, { jobId: job.id, userId, lat: input.lat, lng: input.lng, radiusKm: input.radiusKm, businessType: input.businessType, keywords: input.keywords, queryContextTerms: input.queryContextTerms, }); return { job: completedJob, totalResults: completedJob.totalResults, }; } catch (error) { await failSearchJob(db, job.id); throw error; } }