diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..59b1db2 --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 5095afc..311a3c5 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/db/datasets/README.md b/db/datasets/README.md new file mode 100644 index 0000000..31086e1 --- /dev/null +++ b/db/datasets/README.md @@ -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`. diff --git a/TODO.md b/db/datasets/postal/.gitkeep similarity index 100% rename from TODO.md rename to db/datasets/postal/.gitkeep diff --git a/db/scripts/build-postal-neighbors.ts b/db/scripts/build-postal-neighbors.ts new file mode 100644 index 0000000..31f7a93 --- /dev/null +++ b/db/scripts/build-postal-neighbors.ts @@ -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(); diff --git a/db/scripts/import-postal-areas.ts b/db/scripts/import-postal-areas.ts new file mode 100644 index 0000000..a4357b8 --- /dev/null +++ b/db/scripts/import-postal-areas.ts @@ -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(); diff --git a/db/scripts/migrate.ts b/db/scripts/migrate.ts index 7753c87..289da08 100644 --- a/db/scripts/migrate.ts +++ b/db/scripts/migrate.ts @@ -1,4 +1,3 @@ -import 'dotenv/config'; import { readdir, readFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; diff --git a/db/scripts/postal-import-utils.ts b/db/scripts/postal-import-utils.ts new file mode 100644 index 0000000..83e3af2 --- /dev/null +++ b/db/scripts/postal-import-utils.ts @@ -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 | 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 | 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; +} diff --git a/db/scripts/seed-postal-placeholder.ts b/db/scripts/seed-postal-placeholder.ts deleted file mode 100644 index 88639d4..0000000 --- a/db/scripts/seed-postal-placeholder.ts +++ /dev/null @@ -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.'); diff --git a/metadata.json b/metadata.json deleted file mode 100644 index c32275f..0000000 --- a/metadata.json +++ /dev/null @@ -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"] -} diff --git a/package.json b/package.json index 81a0680..67d4c38 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/server/src/app.ts b/server/src/app.ts index 7724dcd..58bac6b 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -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; } diff --git a/server/src/config/env.ts b/server/src/config/env.ts index c091cc2..0729d12 100644 --- a/server/src/config/env.ts +++ b/server/src/config/env.ts @@ -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'), diff --git a/server/src/deep-research/repository.ts b/server/src/deep-research/repository.ts new file mode 100644 index 0000000..078ae56 --- /dev/null +++ b/server/src/deep-research/repository.ts @@ -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 { + const result = await db.query( + `${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( + `${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 { + 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), + }; +} diff --git a/server/src/deep-research/service.ts b/server/src/deep-research/service.ts new file mode 100644 index 0000000..685ab0b --- /dev/null +++ b/server/src/deep-research/service.ts @@ -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 { + return listDeepResearchBatchesForUser(db, userId); +} + +export async function getDeepResearchBatchDetail(db: Pool, userId: string, batchId: string): Promise { + return getDeepResearchBatchDetailForUser(db, userId, batchId); +} + +export async function createDeepResearchBatchForUser( + db: Pool, + userId: string, + input: CreateDeepResearchBatchRequest, +): Promise { + 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; +} diff --git a/server/src/index.ts b/server/src/index.ts index 69b4e3b..7040342 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,4 +1,3 @@ -import 'dotenv/config'; import { buildApp } from './app.js'; import { getEnv } from './config/env.js'; diff --git a/server/src/postal/repository.ts b/server/src/postal/repository.ts new file mode 100644 index 0000000..77393b7 --- /dev/null +++ b/server/src/postal/repository.ts @@ -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( + ` + 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( + ` + 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; +} diff --git a/server/src/postal/service.ts b/server/src/postal/service.ts new file mode 100644 index 0000000..59e598e --- /dev/null +++ b/server/src/postal/service.ts @@ -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 { + 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 = { + 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, + }; +} diff --git a/server/src/routes/deep-research.ts b/server/src/routes/deep-research.ts new file mode 100644 index 0000000..e2e73fb --- /dev/null +++ b/server/src/routes/deep-research.ts @@ -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.' }); + } + }); +}; diff --git a/server/src/search/repository.ts b/server/src/search/repository.ts index 4d28f56..13121ae 100644 --- a/server/src/search/repository.ts +++ b/server/src/search/repository.ts @@ -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( + ` + 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( + ` + 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( ` diff --git a/server/src/search/run-search.ts b/server/src/search/run-search.ts index 7c15640..da6bd46 100644 --- a/server/src/search/run-search.ts +++ b/server/src/search/run-search.ts @@ -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 { +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 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 { + 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; + } +} diff --git a/server/src/worker.ts b/server/src/worker.ts index 21582d9..f9b3088 100644 --- a/server/src/worker.ts +++ b/server/src/worker.ts @@ -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'; diff --git a/shared/types.ts b/shared/types.ts index 90c8b43..3b5157e 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -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> { + type: 'Feature'; + geometry: GeoJsonGeometry; + properties: TProperties; +} + +export interface GeoJsonFeatureCollection> { + type: 'FeatureCollection'; + features: Array>; +} + +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; + 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 {} diff --git a/src/App.tsx b/src/App.tsx index 4e4b710..094b91e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(null); const [loading, setLoading] = useState(true); - const [activeTab, setActiveTab] = useState<'setup' | 'dashboard' | 'map'>('setup'); + const [activeTab, setActiveTab] = useState('setup'); const [selectedJobIds, setSelectedJobIds] = useState([]); const [authError, setAuthError] = useState(null); const [authNotice, setAuthNotice] = useState(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 ( - } - 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 (
@@ -291,6 +280,23 @@ export default function App() { ); } + if (!hasValidMapsKey) { + return ( + } + 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 ( )} + {activeTab === 'deepResearch' && } {activeTab === 'dashboard' && } {activeTab === 'map' && } diff --git a/src/components/DeepResearchPreviewMap.tsx b/src/components/DeepResearchPreviewMap.tsx new file mode 100644 index 0000000..a67244b --- /dev/null +++ b/src/components/DeepResearchPreviewMap.tsx @@ -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 ( +
+ )} + style={{ width: '100%', height: '100%' }} + gestureHandling="greedy" + onClick={(event) => { + const latLng = event.detail.latLng; + if (latLng) { + onPinChange(latLng); + } + }} + > + + {pin && ( + + + + )} + + + {!pin && ( +
+ Click anywhere on the map to drop a pin and preview the ZIP/FSA areas included in the deep research run. +
+ )} +
+ ); +} + +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; +} diff --git a/src/components/DeepResearchView.tsx b/src/components/DeepResearchView.tsx new file mode 100644 index 0000000..f080c4f --- /dev/null +++ b/src/components/DeepResearchView.tsx @@ -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(null); + const [businessType, setBusinessType] = useState(''); + const [keywords, setKeywords] = useState(''); + const [propagation, setPropagation] = useState(1); + const [preview, setPreview] = useState(null); + const [batches, setBatches] = useState([]); + const [error, setError] = useState(null); + const [previewError, setPreviewError] = useState(null); + const [isPreviewing, setIsPreviewing] = useState(false); + const [isRunning, setIsRunning] = useState(false); + const [isLoadingBatches, setIsLoadingBatches] = useState(true); + const [activeBatchId, setActiveBatchId] = useState(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 ( +
+
+
+

Deep Research

+

+ 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. +

+
+ +
+
+
+

Planner

+

Configure the deep research batch

+

+ The preview uses local postal boundaries to determine which child research jobs will be created. +

+
+ +
+
+ + 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" + /> +
+ +
+ + 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" + /> +
+ +
+ + setPropagation(Number.parseInt(event.target.value, 10) || 0)} + className="w-full accent-emerald-600" + /> +
+ Base postal area only + {propagation} hop{propagation === 1 ? '' : 's'} + Expand outward +
+
+ +
+
+ + Pin placement +
+

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.

+ {pin &&

Pin: {pin.lat.toFixed(5)}, {pin.lng.toFixed(5)}

} +
+
+ + {previewError && ( +
+ + {previewError} +
+ )} + + {previewSummary && ( +
+
+ + Preview ready +
+

{previewSummary}

+

+ Base area: {preview?.baseArea.displayName} +

+
+ )} + +
+ + +
+
+ +
+ + + {preview && ( +
+
+
+

Preview coverage

+

These postal areas will become child researches in the batch.

+
+
+ {preview.totalAreas} areas +
+
+ +
+ {preview.areas.map((area) => ( + + {area.displayName} · ring {area.propagationRing} + + ))} +
+
+ )} +
+
+ +
+
+
+

Deep Research History

+

Previous deep research batches

+

Review completed or failed batch runs and open their bundled map results.

+
+
+ {batches.length} batches +
+
+ + {error &&
{error}
} + + {isLoadingBatches ? ( +
+ + Loading deep research batches... +
+ ) : batches.length === 0 ? ( +
+

No deep research batches yet.

+

Preview a pin on the map and run your first deep research batch.

+
+ ) : ( +
+ {batches.map((batch) => ( +
+
+
+

{batch.businessType}

+

+ {batch.basePostalCode ? `${batch.basePostalCode} · ${batch.countryCode ?? 'N/A'}` : 'Base postal area unavailable'} +

+
+ + {batch.status} + +
+ +
+
+

Propagation

+

{batch.propagation}

+
+
+

Postal Areas

+

{batch.totalPostalAreas}

+
+
+

Child Jobs

+

{batch.childJobCount}

+
+
+

Leads

+

{batch.totalResults}

+
+
+ + {batch.keywords &&

Keywords: {batch.keywords}

} + +
+ Created {new Date(batch.createdAt).toLocaleDateString()} + +
+
+ ))} +
+ )} +
+
+
+ ); +} + +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'; + } +} diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index e1fbab7..43a0b2a 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -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 (
@@ -42,7 +45,7 @@ export function Layout({ user, activeTab, setActiveTab, onLogout, children }: La {navigation.map((item) => (