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
+37
View File
@@ -0,0 +1,37 @@
# Changelog
All notable changes to this project are documented in this file.
## 2026-03-27
### Changed
- Improved local development networking by making API env loading work with `.env.local`, adding LAN-friendly API URL fallback behavior, and fixing development CORS handling.
- Fixed local research inserts so nullable Google Places coordinates no longer break business upserts.
### Added
- Added postal-area import and adjacency build scripts for US ZIP/ZCTA and Canada FSA datasets.
- Added a dedicated `Deep Research` view with map pin placement, propagation preview overlays, batch history, and bundled map navigation.
- Added backend deep-research preview, batch creation, and batch detail APIs, reusing the existing research engine to create one child search per postal area.
### Removed
- Removed stale local metadata, placeholder postal seeding code, and leftover Supabase-era repository artifacts.
### Changed
- Migrated the app from a Supabase runtime to a local Fastify API with PostgreSQL, PostGIS, and cookie-based session auth.
- Reworked the research experience with the `Leads4less` branding, the renamed `Research` view, a top-form layout, and a filterable grid of research jobs.
- Added local API wiring for research, dashboard, and map flows while keeping selected-job map behavior and broader lead retrieval support.
### Added
- Added local backend scaffolding under `server/` for auth, health checks, search routes, database access, and worker startup.
- Added local database migrations and scripts under `db/`, including the first PostGIS-enabled schema and migration runner.
- Added shared app types and frontend API/auth helpers for the local stack.
### Removed
- Removed the Supabase browser client, Edge Function runtime, and Supabase migration artifacts from the active app stack.
## 2026-03-26
### Added
- Initial Leads4Less app with React and Vite.
- Supabase-backed authentication, lead storage, and search job persistence.
- Research, dashboard, and map views for running searches, browsing leads, and visualizing results on Google Maps.
+18 -1
View File
@@ -21,6 +21,10 @@ Leads4Less is a React + Vite app for finding local business leads, saving them i
3. Run the frontend:
`npm run dev:web`
The local backend and scripts load both `.env` and `.env.local`, with `.env.local` taking precedence, so you can keep the full local setup in one file during development.
If you open the app from another machine on your LAN, set `VITE_API_BASE_URL` and `APP_ORIGIN` to that host instead of `localhost`. In development, the frontend also auto-rewrites a `localhost` API URL to the current browser hostname to make local-network testing easier.
## Local API Setup
1. Ensure PostgreSQL is running locally with PostGIS available.
@@ -35,7 +39,20 @@ Leads4Less is a React + Vite app for finding local business leads, saving them i
- `db/migrations/0001_local_core.sql` creates the local-first schema.
- `db/scripts/migrate.ts` applies migrations in order and records them in `schema_migrations`.
- `db/scripts/seed-postal-placeholder.ts` is a placeholder for ZIP/ZCTA and Canada FSA imports.
- `db/scripts/import-postal-areas.ts` imports US ZIP/ZCTA and Canada FSA polygons from local GeoJSON files.
- `db/scripts/build-postal-neighbors.ts` builds adjacency links for deep research propagation.
- `db/datasets/README.md` documents the expected postal dataset locations and property names.
## Postal Dataset Setup
1. Place your GeoJSON files in:
- `db/datasets/postal/us_zcta.geojson`
- `db/datasets/postal/ca_fsa.geojson`
2. Or override them with:
- `POSTAL_US_DATASET_PATH`
- `POSTAL_CA_DATASET_PATH`
3. Import and build adjacency:
`npm run seed:postal`
## Google Maps Requirements
+38
View File
@@ -0,0 +1,38 @@
# Postal Datasets
Leads4less expects local GeoJSON files for deep research postal overlays.
## Supported v1 datasets
- US ZIP/ZCTA polygons
- Canada FSA polygons
## Default file locations
- `db/datasets/postal/us_zcta.geojson`
- `db/datasets/postal/ca_fsa.geojson`
You can override either path with environment variables when running the import script:
- `POSTAL_US_DATASET_PATH`
- `POSTAL_CA_DATASET_PATH`
## Expected feature properties
The importer tries common field names automatically.
For US ZIP/ZCTA:
- `postal_code`
- `zip`
- `zcta`
- `GEOID20`
- `ZCTA5CE20`
- `ZCTA5CE10`
For Canada FSA:
- `postal_code`
- `fsa`
- `CFSAUID`
- `CFSAUID24`
If your dataset uses different property names, update `db/scripts/import-postal-areas.ts`.
+32
View File
@@ -0,0 +1,32 @@
import { getDbPool } from '../../server/src/db/pool.js';
async function run() {
const pool = getDbPool();
const client = await pool.connect();
try {
await client.query('begin');
await client.query('truncate table public.postal_area_neighbors');
await client.query(`
insert into public.postal_area_neighbors (postal_area_id, neighbor_postal_area_id)
select source.id, neighbor.id
from public.postal_areas source
join public.postal_areas neighbor
on source.country_code = neighbor.country_code
and source.id <> neighbor.id
and ST_Touches(source.geom, neighbor.geom)
`);
await client.query('commit');
const summary = await client.query<{ count: string }>('select count(*)::text as count from public.postal_area_neighbors');
console.log(`Built ${summary.rows[0]?.count ?? '0'} postal adjacency links.`);
} catch (error) {
await client.query('rollback');
throw error;
} finally {
client.release();
await pool.end();
}
}
await run();
+121
View File
@@ -0,0 +1,121 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { getDbPool } from '../../server/src/db/pool.js';
import { getFeatureGeometry, getStringProperty, normalizePostalCode, readFeatureCollection, type PostalDatasetConfig } from './postal-import-utils.js';
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const datasetsRoot = path.resolve(currentDir, '../datasets/postal');
const datasetConfigs: PostalDatasetConfig[] = [
{
countryCode: 'US',
label: 'US ZIP/ZCTA',
filePath: process.env.POSTAL_US_DATASET_PATH || path.join(datasetsRoot, 'us_zcta.geojson'),
postalCodeKeys: ['postal_code', 'zip', 'zcta', 'GEOID20', 'ZCTA5CE20', 'ZCTA5CE10'],
displayNameKeys: ['display_name', 'name', 'NAMELSAD20', 'GEOID20', 'postal_code'],
},
{
countryCode: 'CA',
label: 'Canada FSA',
filePath: process.env.POSTAL_CA_DATASET_PATH || path.join(datasetsRoot, 'ca_fsa.geojson'),
postalCodeKeys: ['postal_code', 'fsa', 'CFSAUID', 'CFSAUID24'],
displayNameKeys: ['display_name', 'name', 'CFSAUID', 'postal_code'],
},
];
async function importDataset(config: PostalDatasetConfig) {
const pool = getDbPool();
const client = await pool.connect();
try {
const collection = await readFeatureCollection(config.filePath);
let insertedCount = 0;
let skippedCount = 0;
await client.query('begin');
for (const [index, feature] of collection.features.entries()) {
const rawPostalCode = getStringProperty(feature.properties, config.postalCodeKeys);
if (!rawPostalCode) {
skippedCount += 1;
continue;
}
const normalizedPostalCode = normalizePostalCode(config.countryCode, rawPostalCode);
if (!normalizedPostalCode) {
skippedCount += 1;
continue;
}
const displayName = getStringProperty(feature.properties, config.displayNameKeys) || normalizedPostalCode;
const geometry = getFeatureGeometry(feature, config.filePath, index);
await client.query(
`
insert into public.postal_areas (
country_code,
postal_code,
display_name,
normalized_postal_code,
geom,
centroid,
search_radius_m,
metadata_json,
created_at,
updated_at
)
values (
$1,
$2,
$3,
$4,
ST_Multi(ST_SetSRID(ST_GeomFromGeoJSON($5), 4326)),
ST_Centroid(ST_SetSRID(ST_GeomFromGeoJSON($5), 4326))::geography,
greatest(1000, round(sqrt(ST_Area(ST_SetSRID(ST_GeomFromGeoJSON($5), 4326)::geography) / pi()))::integer),
$6::jsonb,
now(),
now()
)
on conflict (country_code, normalized_postal_code)
do update set
postal_code = excluded.postal_code,
display_name = excluded.display_name,
geom = excluded.geom,
centroid = excluded.centroid,
search_radius_m = excluded.search_radius_m,
metadata_json = excluded.metadata_json,
updated_at = now()
`,
[
config.countryCode,
rawPostalCode.trim(),
displayName,
normalizedPostalCode,
JSON.stringify(geometry),
JSON.stringify(feature.properties ?? {}),
],
);
insertedCount += 1;
}
await client.query('commit');
console.log(`Imported ${insertedCount} ${config.label} areas from ${config.filePath}. Skipped ${skippedCount}.`);
} catch (error) {
await client.query('rollback');
throw error;
} finally {
client.release();
}
}
async function run() {
for (const config of datasetConfigs) {
await importDataset(config);
}
await getDbPool().end();
}
await run();
-1
View File
@@ -1,4 +1,3 @@
import 'dotenv/config';
import { readdir, readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
+76
View File
@@ -0,0 +1,76 @@
import { readFile } from 'node:fs/promises';
export type FeatureGeometry = {
type: 'Polygon' | 'MultiPolygon';
coordinates: unknown;
};
export type GeoJsonFeature = {
type: 'Feature';
properties?: Record<string, unknown> | null;
geometry?: FeatureGeometry | null;
};
export type GeoJsonFeatureCollection = {
type: 'FeatureCollection';
features: GeoJsonFeature[];
};
export type PostalDatasetConfig = {
countryCode: 'US' | 'CA';
label: string;
filePath: string;
postalCodeKeys: string[];
displayNameKeys: string[];
};
export async function readFeatureCollection(filePath: string) {
const raw = await readFile(filePath, 'utf8');
const parsed = JSON.parse(raw) as GeoJsonFeatureCollection;
if (parsed.type !== 'FeatureCollection' || !Array.isArray(parsed.features)) {
throw new Error(`Dataset at ${filePath} is not a valid GeoJSON FeatureCollection.`);
}
return parsed;
}
export function normalizePostalCode(countryCode: 'US' | 'CA', rawPostalCode: string) {
const trimmed = rawPostalCode.trim();
if (countryCode === 'US') {
const digits = trimmed.replace(/\D/g, '').slice(0, 5);
return digits.length === 5 ? digits : null;
}
const compact = trimmed.replace(/\s+/g, '').toUpperCase();
return compact.length >= 3 ? compact.slice(0, 3) : null;
}
export function getStringProperty(properties: Record<string, unknown> | null | undefined, keys: string[]) {
if (!properties) {
return null;
}
for (const key of keys) {
const value = properties[key];
if (typeof value === 'string' && value.trim().length > 0) {
return value.trim();
}
if (typeof value === 'number' && Number.isFinite(value)) {
return String(value);
}
}
return null;
}
export function getFeatureGeometry(feature: GeoJsonFeature, filePath: string, index: number) {
if (!feature.geometry || (feature.geometry.type !== 'Polygon' && feature.geometry.type !== 'MultiPolygon')) {
throw new Error(`Feature ${index} in ${filePath} is missing a Polygon or MultiPolygon geometry.`);
}
return feature.geometry;
}
-2
View File
@@ -1,2 +0,0 @@
console.log('Postal dataset import is not implemented yet.');
console.log('Next step: add ZIP/ZCTA and Canada FSA import scripts into db/datasets and db/scripts.');
-5
View File
@@ -1,5 +0,0 @@
{
"name": "Leads4Less",
"description": "A web application that collects local business information for a given location and business category, stores it in a searchable database, and presents it in a dashboard, table, and map view for lead generation and list building.",
"requestFramePermissions": ["geolocation"]
}
+3 -1
View File
@@ -14,7 +14,9 @@
"clean": "rm -rf dist dist-server",
"lint": "tsc --noEmit && tsc -p tsconfig.server.json --noEmit",
"migrate": "tsx --tsconfig tsconfig.server.json db/scripts/migrate.ts",
"seed:postal": "tsx --tsconfig tsconfig.server.json db/scripts/seed-postal-placeholder.ts",
"import:postal": "tsx --tsconfig tsconfig.server.json db/scripts/import-postal-areas.ts",
"build:postal-neighbors": "tsx --tsconfig tsconfig.server.json db/scripts/build-postal-neighbors.ts",
"seed:postal": "npm run import:postal && npm run build:postal-neighbors",
"start:api": "node dist-server/server/src/index.js",
"start:worker": "node dist-server/server/src/worker.js"
},
+26 -1
View File
@@ -2,19 +2,43 @@ import Fastify from 'fastify';
import cookie from '@fastify/cookie';
import cors from '@fastify/cors';
import { getEnv } from './config/env.js';
import { deepResearchRoutes } from './routes/deep-research.js';
import { authRoutes } from './routes/auth.js';
import { healthRoutes } from './routes/health.js';
import { searchJobRoutes } from './routes/search-jobs.js';
function parseAllowedOrigins(rawOrigins: string) {
return rawOrigins
.split(',')
.map((origin) => origin.trim())
.filter(Boolean);
}
function resolveCorsOrigin(origin: string | undefined, allowedOrigins: string[], isProduction: boolean) {
if (!origin) {
return true;
}
if (!isProduction) {
return origin;
}
return allowedOrigins.includes(origin) ? origin : false;
}
export async function buildApp() {
const env = getEnv();
const allowedOrigins = parseAllowedOrigins(env.APP_ORIGIN);
const app = Fastify({
logger: true,
});
await app.register(cors, {
origin: env.APP_ORIGIN,
origin(origin, callback) {
callback(null, resolveCorsOrigin(origin, allowedOrigins, env.NODE_ENV === 'production'));
},
credentials: true,
methods: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
});
await app.register(cookie, {
@@ -25,6 +49,7 @@ export async function buildApp() {
await app.register(healthRoutes, { prefix: '/api' });
await app.register(authRoutes, { prefix: '/api' });
await app.register(searchJobRoutes, { prefix: '/api' });
await app.register(deepResearchRoutes, { prefix: '/api' });
return app;
}
+16
View File
@@ -1,5 +1,21 @@
import { existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import dotenv from 'dotenv';
import { z } from 'zod';
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
const envFiles = [
path.join(projectRoot, '.env.local'),
path.join(projectRoot, '.env'),
];
for (const envFile of envFiles) {
if (existsSync(envFile)) {
dotenv.config({ path: envFile, override: false });
}
}
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
APP_HOST: z.string().default('0.0.0.0'),
+230
View File
@@ -0,0 +1,230 @@
import type { Pool, PoolClient } from 'pg';
import type { DeepResearchBatchDetail, DeepResearchBatchSummary, DeepResearchChildJobSummary, JobStatus } from '../../../shared/types.js';
import { listSearchJobsForBatch } from '../search/repository.js';
type DbClient = Pool | PoolClient;
type DeepResearchBatchRow = {
id: string;
user_id: string;
pin_lat: number;
pin_lng: number;
base_postal_code: string | null;
country_code: string | null;
propagation: number;
business_type: string;
keywords: string | null;
status: JobStatus;
total_postal_areas: number;
total_results: number;
child_job_count: number;
completed_job_count: number;
failed_job_count: number;
started_at: string | null;
completed_at: string | null;
created_at: string;
updated_at: string;
};
function mapDeepResearchBatchRow(row: DeepResearchBatchRow): DeepResearchBatchSummary {
return {
id: row.id,
userId: row.user_id,
pinLat: row.pin_lat,
pinLng: row.pin_lng,
basePostalCode: row.base_postal_code ?? undefined,
countryCode: row.country_code ?? undefined,
propagation: row.propagation,
businessType: row.business_type,
keywords: row.keywords ?? undefined,
status: row.status,
totalPostalAreas: row.total_postal_areas,
totalResults: row.total_results,
childJobCount: row.child_job_count,
completedJobCount: row.completed_job_count,
failedJobCount: row.failed_job_count,
startedAt: row.started_at ?? undefined,
completedAt: row.completed_at ?? undefined,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
const batchSummarySelect = `
select
batch.id,
batch.user_id,
batch.pin_lat,
batch.pin_lng,
batch.base_postal_code,
batch.country_code,
batch.propagation,
batch.business_type,
batch.keywords,
batch.status,
batch.total_postal_areas,
batch.total_results,
count(job.id)::int as child_job_count,
count(*) filter (where job.status = 'completed')::int as completed_job_count,
count(*) filter (where job.status = 'failed')::int as failed_job_count,
batch.started_at,
batch.completed_at,
batch.created_at,
batch.updated_at
from public.deep_research_batches batch
left join public.search_jobs job on job.deep_research_batch_id = batch.id
`;
export async function createDeepResearchBatch(
db: DbClient,
userId: string,
input: {
pinLat: number;
pinLng: number;
basePostalCode: string;
countryCode: string;
propagation: number;
businessType: string;
keywords?: string;
totalPostalAreas: number;
},
) {
const startedAt = new Date().toISOString();
const result = await db.query<{ id: string }>(
`
insert into public.deep_research_batches (
user_id,
pin_lat,
pin_lng,
pin_geom,
base_postal_code,
country_code,
propagation,
business_type,
keywords,
status,
total_postal_areas,
total_results,
started_at,
created_at,
updated_at
)
values (
$1,
$2,
$3,
ST_SetSRID(ST_MakePoint($3, $2), 4326)::geography,
$4,
$5,
$6,
$7,
$8,
'running',
$9,
0,
$10,
$10,
$10
)
returning id
`,
[
userId,
input.pinLat,
input.pinLng,
input.basePostalCode,
input.countryCode,
input.propagation,
input.businessType,
input.keywords ?? null,
input.totalPostalAreas,
startedAt,
],
);
return result.rows[0].id;
}
export async function finalizeDeepResearchBatch(
db: DbClient,
batchId: string,
input: { status: JobStatus; totalResults: number },
) {
const completedAt = new Date().toISOString();
await db.query(
`
update public.deep_research_batches
set status = $2,
total_results = $3,
completed_at = $4,
updated_at = $4
where id = $1
`,
[batchId, input.status, input.totalResults, completedAt],
);
}
export async function failDeepResearchBatch(db: DbClient, batchId: string) {
await db.query(
`
update public.deep_research_batches
set status = 'failed', updated_at = now()
where id = $1
`,
[batchId],
);
}
export async function listDeepResearchBatchesForUser(db: DbClient, userId: string): Promise<DeepResearchBatchSummary[]> {
const result = await db.query<DeepResearchBatchRow>(
`${batchSummarySelect}
where batch.user_id = $1
group by batch.id
order by batch.created_at desc
`,
[userId],
);
return result.rows.map(mapDeepResearchBatchRow);
}
export async function getDeepResearchBatchSummaryForUser(db: DbClient, userId: string, batchId: string) {
const result = await db.query<DeepResearchBatchRow>(
`${batchSummarySelect}
where batch.user_id = $1 and batch.id = $2
group by batch.id
limit 1
`,
[userId, batchId],
);
if (result.rowCount === 0) {
return null;
}
return mapDeepResearchBatchRow(result.rows[0]);
}
export async function getDeepResearchBatchDetailForUser(db: DbClient, userId: string, batchId: string): Promise<DeepResearchBatchDetail | null> {
const batch = await getDeepResearchBatchSummaryForUser(db, userId, batchId);
if (!batch) {
return null;
}
const childJobs = await listSearchJobsForBatch(db, userId, batchId);
const mappedChildJobs: DeepResearchChildJobSummary[] = childJobs.map((job) => ({
id: job.id,
name: job.name,
postalCode: job.postalCode,
status: job.status,
totalResults: job.totalResults,
createdAt: job.createdAt,
completedAt: job.completedAt,
}));
return {
...batch,
childJobs: mappedChildJobs,
jobIds: mappedChildJobs.map((job) => job.id),
};
}
+99
View File
@@ -0,0 +1,99 @@
import type { Pool } from 'pg';
import type { CreateDeepResearchBatchRequest, DeepResearchBatchDetail, DeepResearchBatchSummary, DeepResearchPreviewRequest, JobStatus } from '../../../shared/types.js';
import { listPostalAreasByPropagation, findPostalAreaContainingPoint } from '../postal/repository.js';
import { previewDeepResearchForPoint } from '../postal/service.js';
import { runSearchForPostalArea } from '../search/run-search.js';
import {
createDeepResearchBatch,
failDeepResearchBatch,
finalizeDeepResearchBatch,
getDeepResearchBatchDetailForUser,
listDeepResearchBatchesForUser,
} from './repository.js';
function toRadiusKm(searchRadiusMeters: number | null) {
return Math.min(50, Math.max(1, Math.ceil((searchRadiusMeters ?? 1000) / 1000)));
}
export async function listDeepResearchBatches(db: Pool, userId: string): Promise<DeepResearchBatchSummary[]> {
return listDeepResearchBatchesForUser(db, userId);
}
export async function getDeepResearchBatchDetail(db: Pool, userId: string, batchId: string): Promise<DeepResearchBatchDetail | null> {
return getDeepResearchBatchDetailForUser(db, userId, batchId);
}
export async function createDeepResearchBatchForUser(
db: Pool,
userId: string,
input: CreateDeepResearchBatchRequest,
): Promise<DeepResearchBatchDetail> {
const preview = await previewDeepResearchForPoint(db, input as DeepResearchPreviewRequest);
const baseArea = await findPostalAreaContainingPoint(db, input.lat, input.lng);
if (!baseArea) {
throw new Error('No supported postal area was found for the selected pin.');
}
const areaRows = await listPostalAreasByPropagation(db, baseArea.id, input.propagation);
const batchId = await createDeepResearchBatch(db, userId, {
pinLat: input.lat,
pinLng: input.lng,
basePostalCode: preview.baseArea.postalCode,
countryCode: preview.countryCode,
propagation: input.propagation,
businessType: input.businessType,
keywords: input.keywords,
totalPostalAreas: preview.totalAreas,
});
let totalResults = 0;
let hadFailures = false;
try {
for (const area of areaRows) {
if (typeof area.centroid_lat !== 'number' || typeof area.centroid_lng !== 'number') {
hadFailures = true;
continue;
}
try {
const result = await runSearchForPostalArea(db, userId, {
name: `${input.businessType} in ${area.display_name || area.postal_code}`,
city: area.display_name || area.postal_code,
address: area.display_name || area.postal_code,
postalCode: area.postal_code,
countryCode: area.country_code,
radiusKm: toRadiusKm(area.search_radius_m),
businessType: input.businessType,
keywords: input.keywords,
lat: area.centroid_lat,
lng: area.centroid_lng,
deepResearchBatchId: batchId,
postalAreaId: area.id,
queryContextTerms: [area.postal_code, area.country_code],
});
totalResults += result.totalResults;
} catch {
hadFailures = true;
}
}
const finalStatus: JobStatus = hadFailures ? 'failed' : 'completed';
await finalizeDeepResearchBatch(db, batchId, {
status: finalStatus,
totalResults,
});
} catch (error) {
await failDeepResearchBatch(db, batchId);
throw error;
}
const detail = await getDeepResearchBatchDetailForUser(db, userId, batchId);
if (!detail) {
throw new Error('Failed to load the created deep research batch.');
}
return detail;
}
-1
View File
@@ -1,4 +1,3 @@
import 'dotenv/config';
import { buildApp } from './app.js';
import { getEnv } from './config/env.js';
+86
View File
@@ -0,0 +1,86 @@
import type { Pool, PoolClient } from 'pg';
type DbClient = Pool | PoolClient;
export type PostalAreaRow = {
id: string;
country_code: string;
postal_code: string;
normalized_postal_code: string;
display_name: string | null;
centroid_lat: number | null;
centroid_lng: number | null;
search_radius_m: number | null;
propagation_ring: number;
geom_json: string;
};
export async function findPostalAreaContainingPoint(db: DbClient, lat: number, lng: number) {
const result = await db.query<PostalAreaRow>(
`
select
id,
country_code,
postal_code,
normalized_postal_code,
display_name,
ST_Y(centroid::geometry) as centroid_lat,
ST_X(centroid::geometry) as centroid_lng,
search_radius_m,
0 as propagation_ring,
ST_AsGeoJSON(geom) as geom_json
from public.postal_areas
where ST_Covers(geom, ST_SetSRID(ST_MakePoint($2, $1), 4326))
order by ST_Area(geom) asc
limit 1
`,
[lat, lng],
);
if (result.rowCount === 0) {
return null;
}
return result.rows[0];
}
export async function listPostalAreasByPropagation(db: DbClient, basePostalAreaId: string, propagation: number) {
const result = await db.query<PostalAreaRow>(
`
with recursive walk as (
select id, 0::int as propagation_ring
from public.postal_areas
where id = $1
union
select neighbor.neighbor_postal_area_id, walk.propagation_ring + 1
from walk
join public.postal_area_neighbors neighbor on neighbor.postal_area_id = walk.id
where walk.propagation_ring < $2
),
ranked as (
select id, min(propagation_ring)::int as propagation_ring
from walk
group by id
)
select
area.id,
area.country_code,
area.postal_code,
area.normalized_postal_code,
area.display_name,
ST_Y(area.centroid::geometry) as centroid_lat,
ST_X(area.centroid::geometry) as centroid_lng,
area.search_radius_m,
ranked.propagation_ring,
ST_AsGeoJSON(area.geom) as geom_json
from ranked
join public.postal_areas area on area.id = ranked.id
order by ranked.propagation_ring asc, area.postal_code asc
`,
[basePostalAreaId, propagation],
);
return result.rows;
}
+68
View File
@@ -0,0 +1,68 @@
import type { Pool } from 'pg';
import type { DeepResearchOverlayProperties, DeepResearchPreview, DeepResearchPreviewRequest, GeoJsonFeatureCollection, PostalAreaPreview } from '../../../shared/types.js';
import { findPostalAreaContainingPoint, listPostalAreasByPropagation } from './repository.js';
function mapPreviewArea(row: {
id: string;
country_code: string;
postal_code: string;
normalized_postal_code: string;
display_name: string | null;
centroid_lat: number | null;
centroid_lng: number | null;
propagation_ring: number;
}) : PostalAreaPreview {
return {
id: row.id,
countryCode: row.country_code,
postalCode: row.postal_code,
normalizedPostalCode: row.normalized_postal_code,
displayName: row.display_name || row.postal_code,
propagationRing: row.propagation_ring,
centroidLat: row.centroid_lat,
centroidLng: row.centroid_lng,
};
}
export async function previewDeepResearchForPoint(db: Pool, input: DeepResearchPreviewRequest): Promise<DeepResearchPreview> {
const baseAreaRow = await findPostalAreaContainingPoint(db, input.lat, input.lng);
if (!baseAreaRow) {
throw new Error('No supported postal area was found for the selected pin.');
}
if (!['US', 'CA'].includes(baseAreaRow.country_code)) {
throw new Error(`Unsupported country code '${baseAreaRow.country_code}' for deep research.`);
}
const areaRows = await listPostalAreasByPropagation(db, baseAreaRow.id, input.propagation);
const areas = areaRows.map(mapPreviewArea);
const baseArea = areas.find((area) => area.id === baseAreaRow.id) ?? mapPreviewArea(baseAreaRow);
const overlay: GeoJsonFeatureCollection<DeepResearchOverlayProperties> = {
type: 'FeatureCollection',
features: areaRows.map((row) => ({
type: 'Feature',
geometry: JSON.parse(row.geom_json) as { type: 'Polygon' | 'MultiPolygon'; coordinates: unknown },
properties: {
postalAreaId: row.id,
countryCode: row.country_code,
postalCode: row.postal_code,
displayName: row.display_name || row.postal_code,
propagationRing: row.propagation_ring,
},
})),
};
return {
baseArea,
areas,
overlay,
propagation: input.propagation,
countryCode: baseArea.countryCode,
totalAreas: areas.length,
estimatedChildJobs: areas.length,
businessType: input.businessType,
keywords: input.keywords,
};
}
+83
View File
@@ -0,0 +1,83 @@
import type { FastifyPluginAsync } from 'fastify';
import { ZodError, z } from 'zod';
import { requireAuth } from '../auth/middleware.js';
import { getDbPool } from '../db/pool.js';
import { createDeepResearchBatchForUser, getDeepResearchBatchDetail, listDeepResearchBatches } from '../deep-research/service.js';
import { previewDeepResearchForPoint } from '../postal/service.js';
const previewSchema = z.object({
lat: z.number().finite().min(-90).max(90),
lng: z.number().finite().min(-180).max(180),
propagation: z.coerce.number().int().min(0).max(5),
businessType: z.string().trim().min(1),
keywords: z.string().trim().optional(),
});
const batchParamsSchema = z.object({
batchId: z.string().uuid(),
});
export const deepResearchRoutes: FastifyPluginAsync = async (app) => {
app.post('/deep-research/preview', { preHandler: requireAuth }, async (request, reply) => {
try {
const payload = previewSchema.parse(request.body);
const preview = await previewDeepResearchForPoint(getDbPool(), payload);
return reply.send({ preview });
} catch (error) {
if (error instanceof ZodError) {
return reply.code(400).send({ error: error.issues[0]?.message || 'Invalid deep research preview payload.' });
}
if (error instanceof Error && error.message.includes('postal area')) {
return reply.code(404).send({ error: error.message });
}
request.log.error(error);
return reply.code(500).send({ error: error instanceof Error ? error.message : 'Failed to preview deep research.' });
}
});
app.post('/deep-research/batches', { preHandler: requireAuth }, async (request, reply) => {
try {
const payload = previewSchema.parse(request.body);
const batch = await createDeepResearchBatchForUser(getDbPool(), request.authUser!.id, payload);
return reply.code(201).send({ batch });
} catch (error) {
if (error instanceof ZodError) {
return reply.code(400).send({ error: error.issues[0]?.message || 'Invalid deep research batch payload.' });
}
if (error instanceof Error && error.message.includes('postal area')) {
return reply.code(404).send({ error: error.message });
}
request.log.error(error);
return reply.code(500).send({ error: error instanceof Error ? error.message : 'Failed to run deep research.' });
}
});
app.get('/deep-research/batches', { preHandler: requireAuth }, async (request) => {
const batches = await listDeepResearchBatches(getDbPool(), request.authUser!.id);
return { batches };
});
app.get('/deep-research/batches/:batchId', { preHandler: requireAuth }, async (request, reply) => {
try {
const { batchId } = batchParamsSchema.parse(request.params);
const batch = await getDeepResearchBatchDetail(getDbPool(), request.authUser!.id, batchId);
if (!batch) {
return reply.code(404).send({ error: 'Deep research batch not found.' });
}
return { batch };
} catch (error) {
if (error instanceof ZodError) {
return reply.code(400).send({ error: error.issues[0]?.message || 'Invalid deep research batch id.' });
}
request.log.error(error);
return reply.code(500).send({ error: 'Failed to load deep research batch.' });
}
});
};
+103 -1
View File
@@ -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>(
`
+115 -29
View File
@@ -1,37 +1,46 @@
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> {
const env = getEnv();
const job = await createSearchJob(db, userId, payload);
const jobId = job.id;
try {
if (!env.GOOGLE_MAPS_SERVER_KEY) {
throw new Error('GOOGLE_MAPS_SERVER_KEY is required for running research.');
}
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(' '),
lat: geocoded.lat,
lng: geocoded.lng,
radiusKm: payload.radiusKm,
});
const matchedKeywords = payload.keywords
? payload.keywords
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;
@@ -40,10 +49,10 @@ export async function runSearchForUser(db: Pool, userId: string, payload: RunSea
continue;
}
const businessId = await upsertBusiness(db, userId, buildBusinessPayload(place, payload.businessType));
const businessId = await upsertBusiness(db, input.userId, buildBusinessPayload(place, input.businessType));
await upsertSearchJobResult(db, {
userId,
searchJobId: jobId,
userId: input.userId,
searchJobId: input.jobId,
businessId,
matchedKeywords: matchedKeywords.length > 0 ? matchedKeywords : null,
rank: index + 1,
@@ -53,13 +62,90 @@ export async function runSearchForUser(db: Pool, userId: string, payload: RunSea
totalResults += 1;
}
const completedJob = await completeSearchJob(db, jobId, totalResults);
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.');
}
const geocoded = await geocodeLocation(payload.location, env.GOOGLE_MAPS_SERVER_KEY);
await updateSearchJobCenter(db, jobId, geocoded.lat, geocoded.lng);
const completedJob = await executeSearchJobAtCoordinates(db, {
jobId,
userId,
lat: geocoded.lat,
lng: geocoded.lng,
radiusKm: payload.radiusKm,
businessType: payload.businessType,
keywords: payload.keywords,
});
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;
}
}
-1
View File
@@ -1,4 +1,3 @@
import 'dotenv/config';
import { getBoss, stopBoss } from './db/boss.js';
import { getEnv } from './config/env.js';
import { registerJobs } from './jobs/register-jobs.js';
+94
View File
@@ -12,3 +12,97 @@ export interface AppUser {
export interface SessionUser extends AppUser {
sessionId: string;
}
export interface GeoJsonGeometry {
type: 'Polygon' | 'MultiPolygon';
coordinates: unknown;
}
export interface GeoJsonFeature<TProperties = Record<string, unknown>> {
type: 'Feature';
geometry: GeoJsonGeometry;
properties: TProperties;
}
export interface GeoJsonFeatureCollection<TProperties = Record<string, unknown>> {
type: 'FeatureCollection';
features: Array<GeoJsonFeature<TProperties>>;
}
export interface PostalAreaPreview {
id: string;
countryCode: string;
postalCode: string;
normalizedPostalCode: string;
displayName: string;
propagationRing: number;
centroidLat: number | null;
centroidLng: number | null;
}
export interface DeepResearchOverlayProperties {
postalAreaId: string;
countryCode: string;
postalCode: string;
displayName: string;
propagationRing: number;
}
export interface DeepResearchPreviewRequest {
lat: number;
lng: number;
propagation: number;
businessType: string;
keywords?: string;
}
export interface DeepResearchPreview {
baseArea: PostalAreaPreview;
areas: PostalAreaPreview[];
overlay: GeoJsonFeatureCollection<DeepResearchOverlayProperties>;
propagation: number;
countryCode: string;
totalAreas: number;
estimatedChildJobs: number;
businessType: string;
keywords?: string;
}
export interface DeepResearchChildJobSummary {
id: string;
name: string;
postalCode?: string;
status: JobStatus;
totalResults: number;
createdAt: string;
completedAt?: string;
}
export interface DeepResearchBatchSummary {
id: string;
userId: string;
pinLat: number;
pinLng: number;
basePostalCode?: string;
countryCode?: string;
propagation: number;
businessType: string;
keywords?: string;
status: JobStatus;
totalPostalAreas: number;
totalResults: number;
childJobCount: number;
completedJobCount: number;
failedJobCount: number;
startedAt?: string;
completedAt?: string;
createdAt: string;
updatedAt: string;
}
export interface DeepResearchBatchDetail extends DeepResearchBatchSummary {
childJobs: DeepResearchChildJobSummary[];
jobIds: string[];
}
export interface CreateDeepResearchBatchRequest extends DeepResearchPreviewRequest {}
+26 -19
View File
@@ -1,7 +1,8 @@
import { type ReactElement, type SVGProps, useEffect, useState } from 'react';
import { APIProvider } from '@vis.gl/react-google-maps';
import { AlertCircle, Briefcase, LogIn, ShieldAlert, UserPlus } from 'lucide-react';
import { Layout } from './components/Layout';
import { DeepResearchView } from './components/DeepResearchView';
import { Layout, type AppTab } from './components/Layout';
import { SearchSetup } from './components/SearchSetup';
import { Dashboard } from './components/Dashboard';
import { MapView } from './components/MapView';
@@ -15,7 +16,7 @@ const hasValidMapsKey = Boolean(GOOGLE_MAPS_API_KEY) && GOOGLE_MAPS_API_KEY !==
export default function App() {
const [user, setUser] = useState<AppUser | null>(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<'setup' | 'dashboard' | 'map'>('setup');
const [activeTab, setActiveTab] = useState<AppTab>('setup');
const [selectedJobIds, setSelectedJobIds] = useState<string[]>([]);
const [authError, setAuthError] = useState<string | null>(null);
const [authNotice, setAuthNotice] = useState<string | null>(null);
@@ -79,6 +80,11 @@ export default function App() {
setSelectedJobIds([]);
};
const handleShowJobIdsOnMap = (jobIds: string[]) => {
setSelectedJobIds(jobIds);
setActiveTab('map');
};
const handleLogin = async () => {
setAuthError(null);
setAuthNotice(null);
@@ -146,23 +152,6 @@ export default function App() {
);
}
if (!hasValidMapsKey) {
return (
<ConfigScreen
icon={<MapIcon className="h-8 w-8" />}
title="Google Maps API Key Required"
description="Add a browser key for map rendering and a server key for the local search API."
steps={[
'Create a Google Maps Platform API key for the browser app.',
'Set VITE_GOOGLE_MAPS_PLATFORM_KEY locally for the frontend.',
'Set GOOGLE_MAPS_SERVER_KEY for the local API server.',
'Enable Geocoding API and Places API in Google Cloud.',
]}
footer="The app will start once the browser key is available."
/>
);
}
if (!user) {
return (
<div className="flex h-screen flex-col items-center justify-center bg-stone-50 p-4">
@@ -291,6 +280,23 @@ export default function App() {
);
}
if (!hasValidMapsKey) {
return (
<ConfigScreen
icon={<MapIcon className="h-8 w-8" />}
title="Google Maps API Key Required"
description="Add a browser key for map rendering and a server key for the local search API."
steps={[
'Create a Google Maps Platform API key for the browser app.',
'Set VITE_GOOGLE_MAPS_PLATFORM_KEY locally for the frontend.',
'Set GOOGLE_MAPS_SERVER_KEY for the local API server.',
'Enable Geocoding API and Places API in Google Cloud.',
]}
footer="You can create a local account without the maps key, but the workspace needs it before loading."
/>
);
}
return (
<APIProvider apiKey={GOOGLE_MAPS_API_KEY} version="weekly">
<Layout
@@ -309,6 +315,7 @@ export default function App() {
onClearSelection={handleClearSelectedJobs}
/>
)}
{activeTab === 'deepResearch' && <DeepResearchView onShowBatchOnMap={handleShowJobIdsOnMap} />}
{activeTab === 'dashboard' && <Dashboard user={user} />}
{activeTab === 'map' && <MapView user={user} jobIds={selectedJobIds} />}
</Layout>
+121
View File
@@ -0,0 +1,121 @@
import React, { useEffect, useMemo } from 'react';
import { AdvancedMarker, Map, Pin, useMap } from '@vis.gl/react-google-maps';
import type { DeepResearchPreview } from '../../shared/types';
interface DeepResearchPreviewMapProps {
pin: google.maps.LatLngLiteral | null;
preview: DeepResearchPreview | null;
onPinChange: (nextPin: google.maps.LatLngLiteral) => void;
}
const ringPalette = ['#059669', '#10b981', '#34d399', '#6ee7b7', '#a7f3d0', '#d1fae5'];
export function DeepResearchPreviewMap({ pin, preview, onPinChange }: DeepResearchPreviewMapProps) {
const defaultCenter = useMemo(() => {
if (pin) {
return pin;
}
const baseArea = preview?.baseArea;
if (baseArea?.centroidLat != null && baseArea?.centroidLng != null) {
return { lat: baseArea.centroidLat, lng: baseArea.centroidLng };
}
return { lat: 39.5, lng: -98.35 };
}, [pin, preview]);
return (
<div className="relative h-[440px] overflow-hidden rounded-3xl border border-stone-200 bg-stone-100 shadow-sm">
<Map
defaultCenter={defaultCenter}
defaultZoom={5}
mapId="DEMO_MAP_ID"
{...({ internalUsageAttributionIds: ['gmp_mcp_codeassist_v1_aistudio'] } as Record<string, unknown>)}
style={{ width: '100%', height: '100%' }}
gestureHandling="greedy"
onClick={(event) => {
const latLng = event.detail.latLng;
if (latLng) {
onPinChange(latLng);
}
}}
>
<PreviewOverlay overlay={preview?.overlay ?? null} pin={pin} preview={preview} />
{pin && (
<AdvancedMarker position={pin}>
<Pin background="#059669" glyphColor="#fff" borderColor="#064e3b" />
</AdvancedMarker>
)}
</Map>
{!pin && (
<div className="pointer-events-none absolute inset-x-6 top-6 rounded-2xl border border-white/20 bg-white/90 p-4 text-sm text-stone-600 shadow-lg backdrop-blur-sm">
Click anywhere on the map to drop a pin and preview the ZIP/FSA areas included in the deep research run.
</div>
)}
</div>
);
}
function PreviewOverlay({
overlay,
pin,
preview,
}: {
overlay: DeepResearchPreview['overlay'] | null;
pin: google.maps.LatLngLiteral | null;
preview: DeepResearchPreview | null;
}) {
const map = useMap();
useEffect(() => {
if (!map || !overlay) {
return;
}
const addedFeatures = map.data.addGeoJson(overlay as never);
map.data.setStyle((feature) => {
const ring = Number(feature.getProperty('propagationRing') ?? 0);
const color = ringPalette[Math.min(ring, ringPalette.length - 1)];
return {
fillColor: color,
fillOpacity: ring === 0 ? 0.28 : 0.16,
strokeColor: color,
strokeWeight: ring === 0 ? 3 : 2,
clickable: false,
};
});
return () => {
addedFeatures.forEach((feature) => map.data.remove(feature));
};
}, [map, overlay]);
useEffect(() => {
if (!map) {
return;
}
const bounds = new google.maps.LatLngBounds();
let hasBounds = false;
if (pin) {
bounds.extend(pin);
hasBounds = true;
}
preview?.areas.forEach((area) => {
if (area.centroidLat != null && area.centroidLng != null) {
bounds.extend({ lat: area.centroidLat, lng: area.centroidLng });
hasBounds = true;
}
});
if (hasBounds) {
map.fitBounds(bounds, 60);
}
}, [map, pin, preview]);
return null;
}
+372
View File
@@ -0,0 +1,372 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { AlertCircle, Crosshair, Loader2, MapPinned, Sparkles } from 'lucide-react';
import type { DeepResearchBatchSummary, DeepResearchPreview } from '../../shared/types';
import { createDeepResearchBatch, getDeepResearchBatch, listDeepResearchBatches, previewDeepResearch } from '../lib/database';
import { DeepResearchPreviewMap } from './DeepResearchPreviewMap';
interface DeepResearchViewProps {
onShowBatchOnMap: (jobIds: string[]) => void;
}
export function DeepResearchView({ onShowBatchOnMap }: DeepResearchViewProps) {
const [pin, setPin] = useState<google.maps.LatLngLiteral | null>(null);
const [businessType, setBusinessType] = useState('');
const [keywords, setKeywords] = useState('');
const [propagation, setPropagation] = useState(1);
const [preview, setPreview] = useState<DeepResearchPreview | null>(null);
const [batches, setBatches] = useState<DeepResearchBatchSummary[]>([]);
const [error, setError] = useState<string | null>(null);
const [previewError, setPreviewError] = useState<string | null>(null);
const [isPreviewing, setIsPreviewing] = useState(false);
const [isRunning, setIsRunning] = useState(false);
const [isLoadingBatches, setIsLoadingBatches] = useState(true);
const [activeBatchId, setActiveBatchId] = useState<string | null>(null);
const refreshBatches = useCallback(async () => {
setIsLoadingBatches(true);
try {
const nextBatches = await listDeepResearchBatches();
setBatches(nextBatches);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load deep research history.');
} finally {
setIsLoadingBatches(false);
}
}, []);
useEffect(() => {
void refreshBatches();
}, [refreshBatches]);
useEffect(() => {
setPreview(null);
setPreviewError(null);
}, [businessType, keywords, pin?.lat, pin?.lng, propagation]);
const canPreview = Boolean(pin && businessType.trim());
const previewSummary = useMemo(() => {
if (!preview) {
return null;
}
return `${preview.totalAreas} postal areas across ${preview.countryCode} with ${preview.estimatedChildJobs} child researches.`;
}, [preview]);
const handlePreview = async () => {
if (!pin || !businessType.trim()) {
setPreviewError('Drop a pin and add a business type before previewing deep research.');
return;
}
setIsPreviewing(true);
setPreviewError(null);
try {
const nextPreview = await previewDeepResearch({
lat: pin.lat,
lng: pin.lng,
propagation,
businessType: businessType.trim(),
keywords: keywords.trim() || undefined,
});
setPreview(nextPreview);
} catch (err) {
setPreviewError(err instanceof Error ? err.message : 'Failed to preview deep research.');
} finally {
setIsPreviewing(false);
}
};
const handleRunDeepResearch = async () => {
if (!pin || !businessType.trim()) {
setPreviewError('Drop a pin and add a business type before running deep research.');
return;
}
setIsRunning(true);
setPreviewError(null);
try {
const batch = await createDeepResearchBatch({
lat: pin.lat,
lng: pin.lng,
propagation,
businessType: businessType.trim(),
keywords: keywords.trim() || undefined,
});
setActiveBatchId(batch.id);
await refreshBatches();
if (batch.jobIds.length > 0) {
onShowBatchOnMap(batch.jobIds);
}
} catch (err) {
setPreviewError(err instanceof Error ? err.message : 'Failed to run deep research.');
} finally {
setActiveBatchId(null);
setIsRunning(false);
}
};
const handleOpenBatchOnMap = async (batchId: string) => {
setActiveBatchId(batchId);
setError(null);
try {
const batch = await getDeepResearchBatch(batchId);
if (batch.jobIds.length === 0) {
setError('This deep research batch does not have child jobs yet.');
return;
}
onShowBatchOnMap(batch.jobIds);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load batch details.');
} finally {
setActiveBatchId(null);
}
};
return (
<div className="flex-1 overflow-y-auto bg-stone-50 p-6 sm:p-8">
<div className="mx-auto max-w-7xl space-y-8">
<header className="space-y-2">
<h1 className="text-3xl font-bold text-stone-900">Deep Research</h1>
<p className="max-w-3xl text-stone-600">
Drop a pin, choose a propagation depth, preview the ZIP or FSA areas that will be covered, and run one bundled research batch across every adjacent postal area.
</p>
</header>
<section className="grid grid-cols-1 gap-6 xl:grid-cols-[360px_minmax(0,1fr)]">
<div className="space-y-6 rounded-3xl border border-stone-200 bg-white p-6 shadow-sm">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-emerald-600">Planner</p>
<h2 className="mt-2 text-2xl font-bold text-stone-900">Configure the deep research batch</h2>
<p className="mt-2 text-sm text-stone-600">
The preview uses local postal boundaries to determine which child research jobs will be created.
</p>
</div>
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-semibold text-stone-700">Business Type</label>
<input
type="text"
value={businessType}
onChange={(event) => setBusinessType(event.target.value)}
placeholder="e.g. dentists, HVAC, bakeries"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-sm outline-none transition-all focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500"
/>
</div>
<div>
<label className="mb-2 block text-sm font-semibold text-stone-700">Keywords</label>
<input
type="text"
value={keywords}
onChange={(event) => setKeywords(event.target.value)}
placeholder="Optional comma-separated keywords"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-sm outline-none transition-all focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500"
/>
</div>
<div>
<label className="mb-2 block text-sm font-semibold text-stone-700">Propagation</label>
<input
type="range"
min="0"
max="5"
value={propagation}
onChange={(event) => setPropagation(Number.parseInt(event.target.value, 10) || 0)}
className="w-full accent-emerald-600"
/>
<div className="mt-2 flex items-center justify-between text-xs text-stone-500">
<span>Base postal area only</span>
<span className="rounded-full bg-emerald-50 px-3 py-1 font-semibold text-emerald-700">{propagation} hop{propagation === 1 ? '' : 's'}</span>
<span>Expand outward</span>
</div>
</div>
<div className="rounded-2xl border border-stone-200 bg-stone-50 p-4 text-sm text-stone-600">
<div className="flex items-center gap-2 font-semibold text-stone-900">
<Crosshair className="h-4 w-4 text-emerald-600" />
Pin placement
</div>
<p className="mt-2">Click the map to drop a pin. The preview will find the containing ZIP or FSA and expand to adjacent postal areas based on the propagation depth.</p>
{pin && <p className="mt-3 font-medium text-stone-800">Pin: {pin.lat.toFixed(5)}, {pin.lng.toFixed(5)}</p>}
</div>
</div>
{previewError && (
<div className="flex items-start gap-3 rounded-2xl border border-red-100 bg-red-50 p-4 text-sm text-red-700">
<AlertCircle className="mt-0.5 h-5 w-5 flex-shrink-0" />
<span>{previewError}</span>
</div>
)}
{previewSummary && (
<div className="rounded-2xl border border-emerald-200 bg-emerald-50/70 p-4 text-sm text-emerald-900">
<div className="flex items-center gap-2 font-semibold">
<Sparkles className="h-4 w-4" />
Preview ready
</div>
<p className="mt-2">{previewSummary}</p>
<p className="mt-2 text-emerald-800">
Base area: <span className="font-semibold">{preview?.baseArea.displayName}</span>
</p>
</div>
)}
<div className="flex flex-col gap-3 sm:flex-row">
<button
type="button"
onClick={() => void handlePreview()}
disabled={!canPreview || isPreviewing || isRunning}
className="flex flex-1 items-center justify-center gap-2 rounded-xl border border-stone-200 bg-white px-4 py-3 text-sm font-semibold text-stone-700 transition-all hover:bg-stone-50 disabled:cursor-not-allowed disabled:opacity-50"
>
{isPreviewing ? <Loader2 className="h-4 w-4 animate-spin" /> : <MapPinned className="h-4 w-4" />}
Preview areas
</button>
<button
type="button"
onClick={() => void handleRunDeepResearch()}
disabled={!canPreview || isPreviewing || isRunning}
className="flex flex-1 items-center justify-center gap-2 rounded-xl bg-emerald-600 px-4 py-3 text-sm font-semibold text-white transition-all hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{isRunning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
Run deep research
</button>
</div>
</div>
<div className="space-y-4">
<DeepResearchPreviewMap pin={pin} preview={preview} onPinChange={setPin} />
{preview && (
<div className="rounded-3xl border border-stone-200 bg-white p-6 shadow-sm">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 className="text-lg font-bold text-stone-900">Preview coverage</h3>
<p className="mt-1 text-sm text-stone-600">These postal areas will become child researches in the batch.</p>
</div>
<div className="rounded-full border border-stone-200 bg-stone-50 px-4 py-2 text-sm font-medium text-stone-700">
{preview.totalAreas} areas
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{preview.areas.map((area) => (
<span
key={area.id}
className={`rounded-full px-3 py-1 text-xs font-semibold ${
area.propagationRing === 0 ? 'bg-emerald-100 text-emerald-800' : 'bg-stone-100 text-stone-700'
}`}
>
{area.displayName} · ring {area.propagationRing}
</span>
))}
</div>
</div>
)}
</div>
</section>
<section className="space-y-5">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-stone-500">Deep Research History</p>
<h2 className="mt-2 text-2xl font-bold text-stone-900">Previous deep research batches</h2>
<p className="mt-2 text-sm text-stone-600">Review completed or failed batch runs and open their bundled map results.</p>
</div>
<div className="rounded-full border border-stone-200 bg-white px-4 py-2 text-sm font-medium text-stone-600 shadow-sm">
{batches.length} batches
</div>
</div>
{error && <div className="rounded-xl border border-red-100 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>}
{isLoadingBatches ? (
<div className="flex items-center gap-3 rounded-2xl border border-stone-200 bg-white p-5 text-sm text-stone-500 shadow-sm">
<Loader2 className="h-4 w-4 animate-spin" />
Loading deep research batches...
</div>
) : batches.length === 0 ? (
<div className="rounded-2xl border border-dashed border-stone-300 bg-white p-10 text-center shadow-sm">
<p className="text-lg font-semibold text-stone-900">No deep research batches yet.</p>
<p className="mt-2 text-sm text-stone-500">Preview a pin on the map and run your first deep research batch.</p>
</div>
) : (
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
{batches.map((batch) => (
<div key={batch.id} className="rounded-2xl border border-stone-200 bg-white p-5 shadow-sm">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-lg font-bold text-stone-900">{batch.businessType}</p>
<p className="mt-1 text-sm text-stone-500">
{batch.basePostalCode ? `${batch.basePostalCode} · ${batch.countryCode ?? 'N/A'}` : 'Base postal area unavailable'}
</p>
</div>
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${statusBadgeClass(batch.status)}`}>
{batch.status}
</span>
</div>
<div className="mt-4 grid grid-cols-2 gap-3 rounded-2xl bg-stone-50 p-4 text-sm text-stone-600">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-stone-400">Propagation</p>
<p className="mt-1 text-base font-bold text-stone-900">{batch.propagation}</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-stone-400">Postal Areas</p>
<p className="mt-1 text-base font-bold text-stone-900">{batch.totalPostalAreas}</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-stone-400">Child Jobs</p>
<p className="mt-1 text-base font-bold text-stone-900">{batch.childJobCount}</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-stone-400">Leads</p>
<p className="mt-1 text-base font-bold text-stone-900">{batch.totalResults}</p>
</div>
</div>
{batch.keywords && <p className="mt-4 text-sm text-stone-500">Keywords: {batch.keywords}</p>}
<div className="mt-4 flex items-center justify-between border-t border-stone-100 pt-4 text-sm text-stone-500">
<span>Created {new Date(batch.createdAt).toLocaleDateString()}</span>
<button
type="button"
onClick={() => void handleOpenBatchOnMap(batch.id)}
disabled={activeBatchId === batch.id}
className="rounded-xl bg-emerald-600 px-4 py-2 font-semibold text-white transition-all hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{activeBatchId === batch.id ? 'Loading...' : 'Show bundle on map'}
</button>
</div>
</div>
))}
</div>
)}
</section>
</div>
</div>
);
}
function statusBadgeClass(status: DeepResearchBatchSummary['status']) {
switch (status) {
case 'completed':
return 'bg-emerald-100 text-emerald-800';
case 'failed':
return 'bg-red-100 text-red-700';
case 'running':
return 'bg-sky-100 text-sky-700';
case 'stopped':
return 'bg-stone-200 text-stone-700';
case 'pending':
default:
return 'bg-amber-100 text-amber-700';
}
}
+8 -5
View File
@@ -1,18 +1,20 @@
import React from 'react';
import { Search, LayoutDashboard, Map as MapIcon, LogOut, Briefcase } from 'lucide-react';
import { Search, LayoutDashboard, Map as MapIcon, LogOut, Briefcase, MapPinned } from 'lucide-react';
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
import type { AppUser } from '../../shared/types';
import { getUserAvatarUrl, getUserDisplayName } from '../lib/auth';
export type AppTab = 'setup' | 'deepResearch' | 'dashboard' | 'map';
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
interface LayoutProps {
user: AppUser;
activeTab: 'setup' | 'dashboard' | 'map';
setActiveTab: (tab: 'setup' | 'dashboard' | 'map') => void;
activeTab: AppTab;
setActiveTab: (tab: AppTab) => void;
onLogout: () => void;
children: React.ReactNode;
}
@@ -23,9 +25,10 @@ export function Layout({ user, activeTab, setActiveTab, onLogout, children }: La
const navigation = [
{ id: 'setup', name: 'Research', icon: Search },
{ id: 'deepResearch', name: 'Deep Research', icon: MapPinned },
{ id: 'dashboard', name: 'Dashboard', icon: LayoutDashboard },
{ id: 'map', name: 'Map View', icon: MapIcon },
];
] as const;
return (
<div className="flex h-screen bg-stone-50 overflow-hidden">
@@ -42,7 +45,7 @@ export function Layout({ user, activeTab, setActiveTab, onLogout, children }: La
{navigation.map((item) => (
<button
key={item.id}
onClick={() => setActiveTab(item.id as any)}
onClick={() => setActiveTab(item.id)}
className={cn(
"w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all",
activeTab === item.id
+46 -2
View File
@@ -1,4 +1,39 @@
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? '';
function isLoopbackHostname(hostname: string) {
return hostname === 'localhost' || hostname === '127.0.0.1';
}
function normalizeBaseUrl(baseUrl: string) {
return baseUrl.replace(/\/+$/, '');
}
function resolveApiBaseUrl() {
const configuredBaseUrl = (import.meta.env.VITE_API_BASE_URL ?? '').trim();
if (typeof window === 'undefined') {
return normalizeBaseUrl(configuredBaseUrl);
}
const fallbackBaseUrl = `${window.location.protocol}//${window.location.hostname}:4000/api`;
if (!configuredBaseUrl) {
return fallbackBaseUrl;
}
try {
const configuredUrl = new URL(configuredBaseUrl);
if (isLoopbackHostname(configuredUrl.hostname) && !isLoopbackHostname(window.location.hostname)) {
configuredUrl.hostname = window.location.hostname;
return normalizeBaseUrl(configuredUrl.toString());
}
return normalizeBaseUrl(configuredUrl.toString());
} catch {
return normalizeBaseUrl(configuredBaseUrl);
}
}
const apiBaseUrl = resolveApiBaseUrl();
export const hasApiConfig = Boolean(apiBaseUrl);
@@ -7,7 +42,10 @@ export async function apiRequest<T>(path: string, init?: RequestInit): Promise<T
throw new Error('VITE_API_BASE_URL is not configured.');
}
const response = await fetch(`${apiBaseUrl}${path}`, {
let response: Response;
try {
response = await fetch(`${apiBaseUrl}${path}`, {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
@@ -15,6 +53,12 @@ export async function apiRequest<T>(path: string, init?: RequestInit): Promise<T
},
...init,
});
} catch (error) {
throw new Error(
`Failed to reach the local API at ${apiBaseUrl}. Check that the API server is running and that VITE_API_BASE_URL matches the host you opened in the browser.`,
{ cause: error },
);
}
const contentType = response.headers.get('content-type') || '';
const payload = contentType.includes('application/json') ? ((await response.json()) as unknown) : null;
+29
View File
@@ -1,5 +1,6 @@
import { apiRequest } from './api';
import type { Business, SearchJob } from '../types';
import type { CreateDeepResearchBatchRequest, DeepResearchBatchDetail, DeepResearchBatchSummary, DeepResearchPreview, DeepResearchPreviewRequest } from '../../shared/types';
export type SearchJobResultLink = {
businessId: string;
@@ -58,3 +59,31 @@ export async function runSearch(payload: RunSearchPayload): Promise<RunSearchRes
body: JSON.stringify(payload),
});
}
export async function previewDeepResearch(payload: DeepResearchPreviewRequest): Promise<DeepResearchPreview> {
const response = await apiRequest<{ preview: DeepResearchPreview }>('/deep-research/preview', {
method: 'POST',
body: JSON.stringify(payload),
});
return response.preview;
}
export async function createDeepResearchBatch(payload: CreateDeepResearchBatchRequest): Promise<DeepResearchBatchDetail> {
const response = await apiRequest<{ batch: DeepResearchBatchDetail }>('/deep-research/batches', {
method: 'POST',
body: JSON.stringify(payload),
});
return response.batch;
}
export async function listDeepResearchBatches(): Promise<DeepResearchBatchSummary[]> {
const response = await apiRequest<{ batches: DeepResearchBatchSummary[] }>('/deep-research/batches');
return response.batches;
}
export async function getDeepResearchBatch(batchId: string): Promise<DeepResearchBatchDetail> {
const response = await apiRequest<{ batch: DeepResearchBatchDetail }>(`/deep-research/batches/${batchId}`);
return response.batch;
}