169 lines
4.8 KiB
TypeScript
169 lines
4.8 KiB
TypeScript
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<RunSearchResult> {
|
|
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<RunSearchResult> {
|
|
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;
|
|
}
|
|
}
|