Public Access
1
0

feat: add deep research planning and postal batch runs

Add a dedicated Deep Research view with postal-area preview overlays, batch execution, and bundled map results.

Also add postal dataset import tooling and fix local API networking and research insert issues needed to support the new workflow.
This commit is contained in:
pguerrerox
2026-04-05 18:05:04 +00:00
parent a1ba5ee093
commit cc00a439bf
29 changed files with 1860 additions and 82 deletions
+121 -35
View File
@@ -1,15 +1,76 @@
import type { Pool } from 'pg';
import { getEnv } from '../config/env.js';
import { buildBusinessPayload, collectPlaces, geocodeLocation } from './google-places.js';
import { completeSearchJob, createSearchJob, failSearchJob, updateSearchJobCenter, upsertBusiness, upsertSearchJobResult } from './repository.js';
import { completeSearchJob, createSearchJob, createSearchJobForCoordinates, failSearchJob, updateSearchJobCenter, upsertBusiness, upsertSearchJobResult } from './repository.js';
import type { RunSearchInput, RunSearchResult } from './types.js';
export async function runSearchForUser(db: Pool, userId: string, payload: RunSearchInput): Promise<RunSearchResult> {
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 job = 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.');
}
@@ -17,49 +78,74 @@ export async function runSearchForUser(db: Pool, userId: string, payload: RunSea
const geocoded = await geocodeLocation(payload.location, env.GOOGLE_MAPS_SERVER_KEY);
await updateSearchJobCenter(db, jobId, geocoded.lat, geocoded.lng);
const places = await collectPlaces({
apiKey: env.GOOGLE_MAPS_SERVER_KEY,
textQuery: [payload.businessType, payload.keywords].filter(Boolean).join(' '),
const completedJob = await executeSearchJobAtCoordinates(db, {
jobId,
userId,
lat: geocoded.lat,
lng: geocoded.lng,
radiusKm: payload.radiusKm,
businessType: payload.businessType,
keywords: payload.keywords,
});
const matchedKeywords = payload.keywords
? payload.keywords
.split(',')
.map((keyword) => keyword.trim())
.filter(Boolean)
: [];
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, userId, buildBusinessPayload(place, payload.businessType));
await upsertSearchJobResult(db, {
userId,
searchJobId: jobId,
businessId,
matchedKeywords: matchedKeywords.length > 0 ? matchedKeywords : null,
rank: index + 1,
capturedAt,
});
totalResults += 1;
}
const completedJob = await completeSearchJob(db, jobId, totalResults);
return {
job: completedJob,
totalResults,
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;
}
}