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:
@@ -23,6 +23,89 @@ export async function createSearchJob(db: DbClient, userId: string, payload: Run
|
||||
return mapSearchJobRow(result.rows[0]);
|
||||
}
|
||||
|
||||
export async function createSearchJobForCoordinates(
|
||||
db: DbClient,
|
||||
userId: string,
|
||||
input: {
|
||||
name: string;
|
||||
city?: string | null;
|
||||
address?: string | null;
|
||||
postalCode?: string | null;
|
||||
countryCode?: string | null;
|
||||
radiusKm: number;
|
||||
businessType: string;
|
||||
keywords?: string | null;
|
||||
lat: number;
|
||||
lng: number;
|
||||
deepResearchBatchId?: string | null;
|
||||
postalAreaId?: string | null;
|
||||
},
|
||||
) {
|
||||
const now = new Date().toISOString();
|
||||
const result = await db.query<SearchJobRow>(
|
||||
`
|
||||
insert into public.search_jobs (
|
||||
user_id,
|
||||
deep_research_batch_id,
|
||||
postal_area_id,
|
||||
name,
|
||||
city,
|
||||
address,
|
||||
postal_code,
|
||||
country_code,
|
||||
radius_km,
|
||||
business_type,
|
||||
keywords,
|
||||
status,
|
||||
total_results,
|
||||
search_center_geom,
|
||||
started_at,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
values (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6,
|
||||
$7,
|
||||
$8,
|
||||
$9,
|
||||
$10,
|
||||
$11,
|
||||
'running',
|
||||
0,
|
||||
ST_SetSRID(ST_MakePoint($13, $12), 4326)::geography,
|
||||
$14,
|
||||
$14,
|
||||
$14
|
||||
)
|
||||
returning id, user_id, name, city, address, postal_code, radius_km, business_type, keywords,
|
||||
status, total_results, started_at, completed_at, created_at, updated_at
|
||||
`,
|
||||
[
|
||||
userId,
|
||||
input.deepResearchBatchId ?? null,
|
||||
input.postalAreaId ?? null,
|
||||
input.name,
|
||||
input.city ?? null,
|
||||
input.address ?? null,
|
||||
input.postalCode ?? null,
|
||||
input.countryCode ?? null,
|
||||
input.radiusKm,
|
||||
input.businessType,
|
||||
input.keywords ?? null,
|
||||
input.lat,
|
||||
input.lng,
|
||||
now,
|
||||
],
|
||||
);
|
||||
|
||||
return mapSearchJobRow(result.rows[0]);
|
||||
}
|
||||
|
||||
export async function updateSearchJobCenter(db: DbClient, jobId: string, lat: number, lng: number) {
|
||||
await db.query(
|
||||
`
|
||||
@@ -76,7 +159,11 @@ export async function upsertBusiness(db: DbClient, userId: string, business: Bus
|
||||
values (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9,
|
||||
$10, $11, $12, $13, $14, $15, $16,
|
||||
case when $15 is not null and $16 is not null then ST_SetSRID(ST_MakePoint($16, $15), 4326)::geography else null end,
|
||||
case
|
||||
when $15::double precision is not null and $16::double precision is not null
|
||||
then ST_SetSRID(ST_MakePoint($16::double precision, $15::double precision), 4326)::geography
|
||||
else null::geography
|
||||
end,
|
||||
$17::jsonb, $18, $19, $20
|
||||
)
|
||||
on conflict (user_id, source, external_source_id)
|
||||
@@ -161,6 +248,21 @@ export async function listSearchJobsForUser(db: DbClient, userId: string, limit
|
||||
return result.rows.map(mapSearchJobRow);
|
||||
}
|
||||
|
||||
export async function listSearchJobsForBatch(db: DbClient, userId: string, batchId: string) {
|
||||
const result = await db.query<SearchJobRow>(
|
||||
`
|
||||
select id, user_id, name, city, address, postal_code, radius_km, business_type, keywords,
|
||||
status, total_results, started_at, completed_at, created_at, updated_at
|
||||
from public.search_jobs
|
||||
where user_id = $1 and deep_research_batch_id = $2
|
||||
order by created_at asc
|
||||
`,
|
||||
[userId, batchId],
|
||||
);
|
||||
|
||||
return result.rows.map(mapSearchJobRow);
|
||||
}
|
||||
|
||||
export async function getSearchJobForUser(db: DbClient, userId: string, jobId: string) {
|
||||
const result = await db.query<SearchJobRow>(
|
||||
`
|
||||
|
||||
+121
-35
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user