Public Access
1
0

feat: migrate app to local Fastify and Postgres stack

Replace Supabase auth and search runtime with a local Fastify API, PostgreSQL/PostGIS schema, and local session handling. Scaffold the worker and deep-research foundations while keeping the existing research, dashboard, and map flows running on the new backend.
This commit is contained in:
pguerrerox
2026-03-27 13:56:54 +00:00
parent 0e4910805a
commit a1ba5ee093
44 changed files with 3756 additions and 1128 deletions
+10 -8
View File
@@ -1,11 +1,13 @@
# Frontend env vars for the Vite app
VITE_SUPABASE_URL="http://YOUR_SUPABASE_API_HOST"
VITE_SUPABASE_ANON_KEY="YOUR_SUPABASE_ANON_KEY"
VITE_API_BASE_URL="http://localhost:4000/api"
VITE_GOOGLE_MAPS_PLATFORM_KEY="YOUR_BROWSER_MAPS_KEY"
# Backend / Edge Function secrets
# Do not expose these in the browser.
SUPABASE_URL="http://YOUR_SUPABASE_API_HOST"
SUPABASE_ANON_KEY="YOUR_SUPABASE_ANON_KEY"
SUPABASE_SERVICE_ROLE_KEY="YOUR_SUPABASE_SERVICE_ROLE_KEY"
GOOGLE_MAPS_SERVER_KEY="YOUR_SERVER_MAPS_KEY"
# Local backend env vars
DATABASE_URL="postgres://postgres:postgres@localhost:5432/leads4less"
COOKIE_SECRET="CHANGE_ME_IN_LOCAL_ENV"
APP_HOST="0.0.0.0"
APP_PORT="4000"
APP_ORIGIN="http://localhost:3000"
PG_BOSS_SCHEMA="pgboss"
SESSION_TTL_DAYS="30"
GOOGLE_MAPS_SERVER_KEY="YOUR_SERVER_MAPS_KEY"
+1
View File
@@ -1,6 +1,7 @@
node_modules/
build/
dist/
dist-server/
coverage/
.DS_Store
*.log
+30 -24
View File
@@ -1,35 +1,41 @@
# Leads4Less
Leads4Less is a React + Vite app for finding local business leads, saving them in Supabase, and browsing them in dashboard and map views.
Leads4Less is a React + Vite app for finding local business leads, saving them in Postgres, and browsing them in dashboard and map views.
## Stack
- React 19 + Vite
- Supabase Auth + Postgres + Edge Functions
- Google Maps Platform for maps, geocoding, and Places search
- Local Fastify API + pg-boss worker
- PostgreSQL + PostGIS
- Google Maps Platform for browser maps and Places search
## Local App Setup
1. Install dependencies:
`npm install`
2. Copy `.env.example` to `.env.local` and fill in:
- `VITE_SUPABASE_URL`
- `VITE_SUPABASE_ANON_KEY`
2. Copy `.env.example` to `.env.local` and fill in at least:
- `VITE_GOOGLE_MAPS_PLATFORM_KEY`
3. Run the app:
`npm run dev`
## Supabase Setup
1. Create a Supabase project.
2. Enable email/password auth in Supabase Auth.
3. Apply the SQL migration in `supabase/migrations/20260322120000_init.sql`.
4. Deploy the Edge Function in `supabase/functions/run-search/index.ts`.
5. Set these Edge Function secrets in Supabase:
- `SUPABASE_URL`
- `SUPABASE_ANON_KEY`
- `SUPABASE_SERVICE_ROLE_KEY`
- `DATABASE_URL`
- `COOKIE_SECRET`
- `GOOGLE_MAPS_SERVER_KEY`
3. Run the frontend:
`npm run dev:web`
## Local API Setup
1. Ensure PostgreSQL is running locally with PostGIS available.
2. Apply the local database migrations:
`npm run migrate`
3. Start the API:
`npm run dev:api`
4. Start the worker:
`npm run dev:worker`
## Database Layout
- `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.
## Google Maps Requirements
@@ -41,9 +47,9 @@ Enable these Google Cloud APIs for the keys you use:
Use a browser-restricted key for `VITE_GOOGLE_MAPS_PLATFORM_KEY` and a server-side key for `GOOGLE_MAPS_SERVER_KEY`.
## Current Flow
## Current Runtime Flow
1. User signs in with Supabase email/password auth.
2. The app submits a search request to the `run-search` Edge Function.
3. The function geocodes the location, calls Google Places, upserts businesses, and stores job results in Supabase.
4. The dashboard and map load saved leads from Postgres.
1. The React app authenticates through the local Fastify API using cookie-backed sessions.
2. Search requests run through the local API and persist results in PostgreSQL.
3. The worker foundation is available for asynchronous job execution and deep research expansion.
4. The target architecture is fully local auth + Fastify routes + pg-boss workers + PostgreSQL/PostGIS.
+188
View File
@@ -0,0 +1,188 @@
create extension if not exists pgcrypto;
create extension if not exists postgis;
create or replace function public.set_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at = now();
return new;
end;
$$;
create table if not exists public.users (
id uuid primary key default gen_random_uuid(),
email text not null unique,
password_hash text not null,
display_name text,
avatar_url text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table if not exists public.sessions (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references public.users (id) on delete cascade,
token_hash text not null unique,
expires_at timestamptz not null,
last_seen_at timestamptz,
user_agent text,
ip_address inet,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table if not exists public.postal_areas (
id uuid primary key default gen_random_uuid(),
country_code text not null,
postal_code text not null,
display_name text,
normalized_postal_code text not null,
geom geometry(multipolygon, 4326) not null,
centroid geography(point, 4326),
search_radius_m integer,
metadata_json jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint postal_areas_country_code_postal_code_key unique (country_code, normalized_postal_code)
);
create table if not exists public.deep_research_batches (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references public.users (id) on delete cascade,
pin_lat double precision not null,
pin_lng double precision not null,
pin_geom geography(point, 4326),
base_postal_code text,
country_code text,
propagation integer not null default 0,
business_type text not null,
keywords text,
status text not null check (status in ('pending', 'running', 'completed', 'failed', 'stopped')),
total_postal_areas integer not null default 0,
total_results integer not null default 0,
started_at timestamptz,
completed_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table if not exists public.search_jobs (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references public.users (id) on delete cascade,
deep_research_batch_id uuid references public.deep_research_batches (id) on delete set null,
postal_area_id uuid references public.postal_areas (id) on delete set null,
name text not null,
city text,
address text,
postal_code text,
country_code text,
radius_km numeric not null,
business_type text not null,
keywords text,
status text not null check (status in ('pending', 'running', 'completed', 'failed', 'stopped')),
total_results integer not null default 0,
cancel_requested boolean not null default false,
requested_lead_limit integer not null default 60,
search_center_geom geography(point, 4326),
started_at timestamptz,
completed_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table if not exists public.businesses (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references public.users (id) on delete cascade,
external_source_id text,
source text not null,
name text not null,
address text,
city text,
state_province text,
postal_code text,
country text,
phone text,
website text,
rating numeric,
review_count integer,
category text,
hours_json jsonb,
latitude double precision,
longitude double precision,
geom geography(point, 4326),
general_info text,
metadata_json jsonb,
first_seen_at timestamptz,
last_seen_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint businesses_user_source_external_source_key unique (user_id, source, external_source_id)
);
create table if not exists public.search_job_results (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references public.users (id) on delete cascade,
search_job_id uuid not null references public.search_jobs (id) on delete cascade,
business_id uuid not null references public.businesses (id) on delete cascade,
matched_keywords text[],
rank integer,
captured_at timestamptz not null default now(),
constraint search_job_results_job_business_key unique (search_job_id, business_id)
);
create table if not exists public.postal_area_neighbors (
postal_area_id uuid not null references public.postal_areas (id) on delete cascade,
neighbor_postal_area_id uuid not null references public.postal_areas (id) on delete cascade,
created_at timestamptz not null default now(),
primary key (postal_area_id, neighbor_postal_area_id),
check (postal_area_id <> neighbor_postal_area_id)
);
create index if not exists sessions_user_id_idx on public.sessions (user_id);
create index if not exists sessions_expires_at_idx on public.sessions (expires_at);
create index if not exists search_jobs_user_created_at_idx on public.search_jobs (user_id, created_at desc);
create index if not exists search_jobs_batch_idx on public.search_jobs (deep_research_batch_id);
create index if not exists businesses_user_created_at_idx on public.businesses (user_id, created_at desc);
create index if not exists search_job_results_user_job_idx on public.search_job_results (user_id, search_job_id);
create index if not exists deep_research_batches_user_created_at_idx on public.deep_research_batches (user_id, created_at desc);
create index if not exists postal_areas_geom_idx on public.postal_areas using gist (geom);
create index if not exists postal_areas_centroid_idx on public.postal_areas using gist (centroid);
create index if not exists businesses_geom_idx on public.businesses using gist (geom);
drop trigger if exists set_users_updated_at on public.users;
create trigger set_users_updated_at
before update on public.users
for each row
execute function public.set_updated_at();
drop trigger if exists set_sessions_updated_at on public.sessions;
create trigger set_sessions_updated_at
before update on public.sessions
for each row
execute function public.set_updated_at();
drop trigger if exists set_postal_areas_updated_at on public.postal_areas;
create trigger set_postal_areas_updated_at
before update on public.postal_areas
for each row
execute function public.set_updated_at();
drop trigger if exists set_deep_research_batches_updated_at on public.deep_research_batches;
create trigger set_deep_research_batches_updated_at
before update on public.deep_research_batches
for each row
execute function public.set_updated_at();
drop trigger if exists set_search_jobs_updated_at on public.search_jobs;
create trigger set_search_jobs_updated_at
before update on public.search_jobs
for each row
execute function public.set_updated_at();
drop trigger if exists set_businesses_updated_at on public.businesses;
create trigger set_businesses_updated_at
before update on public.businesses
for each row
execute function public.set_updated_at();
+54
View File
@@ -0,0 +1,54 @@
import 'dotenv/config';
import { readdir, readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { getDbPool } from '../../server/src/db/pool.js';
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const migrationsDir = path.resolve(currentDir, '../migrations');
async function run() {
const pool = getDbPool();
const client = await pool.connect();
try {
await client.query(`
create table if not exists public.schema_migrations (
id text primary key,
applied_at timestamptz not null default now()
)
`);
const appliedRows = await client.query<{ id: string }>('select id from public.schema_migrations');
const appliedIds = new Set(appliedRows.rows.map((row) => row.id));
const migrationFiles = (await readdir(migrationsDir))
.filter((entry) => entry.endsWith('.sql'))
.sort((left, right) => left.localeCompare(right));
for (const migrationFile of migrationFiles) {
if (appliedIds.has(migrationFile)) {
continue;
}
const migrationPath = path.join(migrationsDir, migrationFile);
const sql = await readFile(migrationPath, 'utf8');
console.log(`Applying migration ${migrationFile}`);
await client.query('begin');
await client.query(sql);
await client.query('insert into public.schema_migrations (id) values ($1)', [migrationFile]);
await client.query('commit');
}
console.log('Migrations complete');
} catch (error) {
await client.query('rollback');
throw error;
} finally {
client.release();
await pool.end();
}
}
await run();
+2
View File
@@ -0,0 +1,2 @@
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.');
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Leads4Less</title>
<title>Leads4less</title>
</head>
<body>
<div id="root"></div>
+1560 -116
View File
File diff suppressed because it is too large Load Diff
+21 -4
View File
@@ -5,28 +5,45 @@
"type": "module",
"scripts": {
"dev": "vite --port=3000 --host=0.0.0.0",
"dev:web": "vite --port=3000 --host=0.0.0.0",
"dev:api": "tsx --tsconfig tsconfig.server.json server/src/index.ts",
"dev:worker": "tsx --tsconfig tsconfig.server.json server/src/worker.ts",
"build": "vite build",
"build:api": "tsc -p tsconfig.server.json",
"preview": "vite preview",
"clean": "rm -rf dist",
"lint": "tsc --noEmit"
"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",
"start:api": "node dist-server/server/src/index.js",
"start:worker": "node dist-server/server/src/worker.js"
},
"dependencies": {
"@supabase/supabase-js": "^2.57.4",
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.2.0",
"@tailwindcss/vite": "^4.1.14",
"@vis.gl/react-google-maps": "^1.7.1",
"@vitejs/plugin-react": "^5.0.4",
"argon2": "^0.44.0",
"clsx": "^2.1.1",
"dotenv": "^17.3.1",
"fastify": "^5.8.4",
"lucide-react": "^0.546.0",
"papaparse": "^5.5.3",
"pg": "^8.20.0",
"pg-boss": "^12.14.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^3.5.0",
"vite": "^6.2.0"
"vite": "^6.2.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^22.14.0",
"@types/pg": "^8.20.0",
"autoprefixer": "^10.4.21",
"tailwindcss": "^4.1.14",
"tsx": "^4.21.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
+30
View File
@@ -0,0 +1,30 @@
import Fastify from 'fastify';
import cookie from '@fastify/cookie';
import cors from '@fastify/cors';
import { getEnv } from './config/env.js';
import { authRoutes } from './routes/auth.js';
import { healthRoutes } from './routes/health.js';
import { searchJobRoutes } from './routes/search-jobs.js';
export async function buildApp() {
const env = getEnv();
const app = Fastify({
logger: true,
});
await app.register(cors, {
origin: env.APP_ORIGIN,
credentials: true,
});
await app.register(cookie, {
secret: env.COOKIE_SECRET,
hook: 'onRequest',
});
await app.register(healthRoutes, { prefix: '/api' });
await app.register(authRoutes, { prefix: '/api' });
await app.register(searchJobRoutes, { prefix: '/api' });
return app;
}
+2
View File
@@ -0,0 +1,2 @@
export const SESSION_COOKIE_NAME = 'leads4less_session';
export const SESSION_TOKEN_BYTES = 32;
+33
View File
@@ -0,0 +1,33 @@
import type { FastifyReply, FastifyRequest } from 'fastify';
import type { SessionUser } from '../../../shared/types.js';
import { getDbPool } from '../db/pool.js';
import { getSessionTokenFromRequest, getSessionUserByToken } from './sessions.js';
declare module 'fastify' {
interface FastifyRequest {
authUser: SessionUser | null;
}
}
export async function hydrateAuthUser(request: FastifyRequest) {
const token = getSessionTokenFromRequest(request);
if (!token) {
request.authUser = null;
return null;
}
const user = await getSessionUserByToken(getDbPool(), token);
request.authUser = user;
return user;
}
export async function requireAuth(request: FastifyRequest, reply: FastifyReply) {
const user = await hydrateAuthUser(request);
if (!user) {
return reply.code(401).send({ error: 'Unauthorized' });
}
return undefined;
}
+9
View File
@@ -0,0 +1,9 @@
import argon2 from 'argon2';
export async function hashPassword(password: string) {
return argon2.hash(password);
}
export async function verifyPassword(passwordHash: string, password: string) {
return argon2.verify(passwordHash, password);
}
+111
View File
@@ -0,0 +1,111 @@
import { createHash, randomBytes } from 'node:crypto';
import type { FastifyReply, FastifyRequest } from 'fastify';
import type { Pool, PoolClient } from 'pg';
import type { SessionUser } from '../../../shared/types.js';
import { getEnv } from '../config/env.js';
import { SESSION_COOKIE_NAME, SESSION_TOKEN_BYTES } from './constants.js';
type DbClient = Pool | PoolClient;
type SessionRow = {
session_id: string;
id: string;
email: string;
display_name: string | null;
avatar_url: string | null;
created_at: string;
updated_at: string;
};
function hashSessionToken(token: string) {
return createHash('sha256').update(token).digest('hex');
}
function sessionExpiryDate() {
const env = getEnv();
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + env.SESSION_TTL_DAYS);
return expiresAt;
}
function mapSessionRow(row: SessionRow): SessionUser {
return {
sessionId: row.session_id,
id: row.id,
email: row.email,
displayName: row.display_name || row.email.split('@')[0] || 'User',
avatarUrl: row.avatar_url,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export function setSessionCookie(reply: FastifyReply, token: string, expiresAt: Date) {
const env = getEnv();
reply.setCookie(SESSION_COOKIE_NAME, token, {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: env.NODE_ENV === 'production',
expires: expiresAt,
});
}
export function clearSessionCookie(reply: FastifyReply) {
reply.clearCookie(SESSION_COOKIE_NAME, {
path: '/',
sameSite: 'lax',
});
}
export function getSessionTokenFromRequest(request: FastifyRequest) {
const cookies = request.cookies as Record<string, string | undefined>;
return cookies[SESSION_COOKIE_NAME] ?? null;
}
export async function createSession(db: DbClient, userId: string, metadata: { userAgent?: string; ipAddress?: string | null }) {
const token = randomBytes(SESSION_TOKEN_BYTES).toString('hex');
const tokenHash = hashSessionToken(token);
const expiresAt = sessionExpiryDate();
const result = await db.query<{ id: string }>(
`
insert into public.sessions (user_id, token_hash, expires_at, last_seen_at, user_agent, ip_address)
values ($1, $2, $3, now(), $4, $5)
returning id
`,
[userId, tokenHash, expiresAt.toISOString(), metadata.userAgent ?? null, metadata.ipAddress ?? null],
);
return {
sessionId: result.rows[0].id,
token,
expiresAt,
};
}
export async function deleteSessionByToken(db: DbClient, token: string) {
await db.query('delete from public.sessions where token_hash = $1', [hashSessionToken(token)]);
}
export async function getSessionUserByToken(db: DbClient, token: string) {
const tokenHash = hashSessionToken(token);
const result = await db.query<SessionRow>(
`
select s.id as session_id, u.id, u.email, u.display_name, u.avatar_url, u.created_at, u.updated_at
from public.sessions s
join public.users u on u.id = s.user_id
where s.token_hash = $1 and s.expires_at > now()
limit 1
`,
[tokenHash],
);
if (result.rowCount === 0) {
return null;
}
await db.query('update public.sessions set last_seen_at = now() where token_hash = $1', [tokenHash]);
return mapSessionRow(result.rows[0]);
}
+78
View File
@@ -0,0 +1,78 @@
import type { Pool, PoolClient } from 'pg';
import type { AppUser } from '../../../shared/types.js';
type DbClient = Pool | PoolClient;
type UserRow = {
id: string;
email: string;
password_hash: string;
display_name: string | null;
avatar_url: string | null;
created_at: string;
updated_at: string;
};
export type UserWithPassword = AppUser & {
passwordHash: string;
};
export function normalizeEmail(email: string) {
return email.trim().toLowerCase();
}
export function mapUserRow(row: UserRow): UserWithPassword {
return {
id: row.id,
email: row.email,
displayName: row.display_name || row.email.split('@')[0] || 'User',
avatarUrl: row.avatar_url,
createdAt: row.created_at,
updatedAt: row.updated_at,
passwordHash: row.password_hash,
};
}
export function toAppUser(user: UserWithPassword): AppUser {
return {
id: user.id,
email: user.email,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
};
}
export async function getUserByEmail(db: DbClient, email: string) {
const normalizedEmail = normalizeEmail(email);
const result = await db.query<UserRow>(
`
select id, email, password_hash, display_name, avatar_url, created_at, updated_at
from public.users
where lower(email) = $1
limit 1
`,
[normalizedEmail],
);
if (result.rowCount === 0) {
return null;
}
return mapUserRow(result.rows[0]);
}
export async function createUser(db: DbClient, input: { email: string; passwordHash: string; displayName?: string | null }) {
const normalizedEmail = normalizeEmail(input.email);
const result = await db.query<UserRow>(
`
insert into public.users (email, password_hash, display_name)
values ($1, $2, $3)
returning id, email, password_hash, display_name, avatar_url, created_at, updated_at
`,
[normalizedEmail, input.passwordHash, input.displayName?.trim() || null],
);
return mapUserRow(result.rows[0]);
}
+26
View File
@@ -0,0 +1,26 @@
import { z } from 'zod';
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
APP_HOST: z.string().default('0.0.0.0'),
APP_PORT: z.coerce.number().int().positive().default(4000),
APP_ORIGIN: z.string().default('http://localhost:3000'),
DATABASE_URL: z.string().min(1, 'DATABASE_URL is required'),
COOKIE_SECRET: z.string().min(1, 'COOKIE_SECRET is required'),
GOOGLE_MAPS_SERVER_KEY: z.string().optional(),
PG_BOSS_SCHEMA: z.string().default('pgboss'),
SESSION_TTL_DAYS: z.coerce.number().int().positive().default(30),
});
export type AppEnv = z.infer<typeof envSchema>;
let cachedEnv: AppEnv | null = null;
export function getEnv(): AppEnv {
if (cachedEnv) {
return cachedEnv;
}
cachedEnv = envSchema.parse(process.env);
return cachedEnv;
}
+28
View File
@@ -0,0 +1,28 @@
import { PgBoss } from 'pg-boss';
import { getEnv } from '../config/env.js';
let boss: PgBoss | null = null;
export async function getBoss() {
if (boss) {
return boss;
}
const env = getEnv();
boss = new PgBoss({
connectionString: env.DATABASE_URL,
schema: env.PG_BOSS_SCHEMA,
});
await boss.start();
return boss;
}
export async function stopBoss() {
if (!boss) {
return;
}
await boss.stop();
boss = null;
}
+17
View File
@@ -0,0 +1,17 @@
import { Pool } from 'pg';
import { getEnv } from '../config/env.js';
let pool: Pool | null = null;
export function getDbPool() {
if (pool) {
return pool;
}
const env = getEnv();
pool = new Pool({
connectionString: env.DATABASE_URL,
});
return pool;
}
+16
View File
@@ -0,0 +1,16 @@
import 'dotenv/config';
import { buildApp } from './app.js';
import { getEnv } from './config/env.js';
const env = getEnv();
const app = await buildApp();
try {
await app.listen({
host: env.APP_HOST,
port: env.APP_PORT,
});
} catch (error) {
app.log.error(error);
process.exit(1);
}
+2
View File
@@ -0,0 +1,2 @@
export const RUN_SEARCH_JOB = 'search.run';
export const RUN_DEEP_RESEARCH_BATCH_JOB = 'deep-research.run';
+12
View File
@@ -0,0 +1,12 @@
import type { PgBoss } from 'pg-boss';
import { RUN_DEEP_RESEARCH_BATCH_JOB, RUN_SEARCH_JOB } from './names.js';
export async function registerJobs(boss: PgBoss) {
await boss.work(RUN_SEARCH_JOB, async ([job]) => {
console.warn(`Job ${RUN_SEARCH_JOB} is queued but not implemented yet`, job?.id);
});
await boss.work(RUN_DEEP_RESEARCH_BATCH_JOB, async ([job]) => {
console.warn(`Job ${RUN_DEEP_RESEARCH_BATCH_JOB} is queued but not implemented yet`, job?.id);
});
}
+112
View File
@@ -0,0 +1,112 @@
import type { FastifyPluginAsync, FastifyRequest } from 'fastify';
import { ZodError, z } from 'zod';
import { hashPassword, verifyPassword } from '../auth/passwords.js';
import { clearSessionCookie, createSession, deleteSessionByToken, getSessionTokenFromRequest, getSessionUserByToken, setSessionCookie, } from '../auth/sessions.js';
import { createUser, getUserByEmail, toAppUser } from '../auth/users.js';
import { getDbPool } from '../db/pool.js';
const signUpSchema = z.object({
email: z.string().email(),
password: z.string().min(6),
displayName: z.string().trim().min(1).max(120).optional(),
});
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
function getRequestMetadata(request: FastifyRequest) {
return {
userAgent: request.headers['user-agent'],
ipAddress: request.ip || null,
};
}
export const authRoutes: FastifyPluginAsync = async (app) => {
app.get('/auth/me', async (request) => {
const token = getSessionTokenFromRequest(request);
if (!token) {
return { user: null };
}
const user = await getSessionUserByToken(getDbPool(), token);
return { user };
});
app.post('/auth/signup', async (request, reply) => {
try {
const payload = signUpSchema.parse(request.body);
const db = getDbPool();
const existingUser = await getUserByEmail(db, payload.email);
if (existingUser) {
return reply.code(409).send({ error: 'An account with that email already exists.' });
}
const passwordHash = await hashPassword(payload.password);
const user = await createUser(db, {
email: payload.email,
passwordHash,
displayName: payload.displayName,
});
const session = await createSession(db, user.id, getRequestMetadata(request));
setSessionCookie(reply, session.token, session.expiresAt);
return reply.code(201).send({ user: { ...toAppUser(user), sessionId: session.sessionId } });
} catch (error) {
if (error instanceof ZodError) {
return reply.code(400).send({ error: error.issues[0]?.message || 'Invalid signup payload.' });
}
if ((error as { code?: string }).code === '23505') {
return reply.code(409).send({ error: 'An account with that email already exists.' });
}
request.log.error(error);
return reply.code(500).send({ error: 'Failed to create account.' });
}
});
app.post('/auth/login', async (request, reply) => {
try {
const payload = loginSchema.parse(request.body);
const db = getDbPool();
const user = await getUserByEmail(db, payload.email);
if (!user) {
return reply.code(401).send({ error: 'Invalid email or password.' });
}
const isValidPassword = await verifyPassword(user.passwordHash, payload.password);
if (!isValidPassword) {
return reply.code(401).send({ error: 'Invalid email or password.' });
}
const session = await createSession(db, user.id, getRequestMetadata(request));
setSessionCookie(reply, session.token, session.expiresAt);
return { user: { ...toAppUser(user), sessionId: session.sessionId } };
} catch (error) {
if (error instanceof ZodError) {
return reply.code(400).send({ error: error.issues[0]?.message || 'Invalid login payload.' });
}
request.log.error(error);
return reply.code(500).send({ error: 'Failed to sign in.' });
}
});
app.post('/auth/logout', async (request, reply) => {
const token = getSessionTokenFromRequest(request);
if (token) {
await deleteSessionByToken(getDbPool(), token);
}
clearSessionCookie(reply);
return { success: true };
});
};
+9
View File
@@ -0,0 +1,9 @@
import type { FastifyPluginAsync } from 'fastify';
export const healthRoutes: FastifyPluginAsync = async (app) => {
app.get('/health', async () => ({
ok: true,
service: 'leads4less-api',
timestamp: new Date().toISOString(),
}));
};
+75
View File
@@ -0,0 +1,75 @@
import type { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { requireAuth } from '../auth/middleware.js';
import { getDbPool } from '../db/pool.js';
import { listBusinessesForJobIds, listBusinessesForUser, listSearchJobResultLinksForUser, listSearchJobsForUser, getSearchJobForUser } from '../search/repository.js';
import { runSearchForUser } from '../search/run-search.js';
const runSearchSchema = z.object({
name: z.string().trim().min(1).max(160).optional(),
location: z.string().trim().min(1),
radiusKm: z.coerce.number().positive().max(50),
businessType: z.string().trim().min(1),
keywords: z.string().trim().optional(),
});
const jobParamsSchema = z.object({
jobId: z.string().uuid(),
});
export const searchJobRoutes: FastifyPluginAsync = async (app) => {
app.get('/search-jobs', { preHandler: requireAuth }, async (request) => {
const limitValue = typeof request.query === 'object' && request.query && 'limit' in request.query ? Number((request.query as { limit?: string }).limit) : 100;
const jobs = await listSearchJobsForUser(getDbPool(), request.authUser!.id, Number.isFinite(limitValue) ? Math.min(Math.max(limitValue, 1), 200) : 100);
return { jobs };
});
app.post('/search-jobs', { preHandler: requireAuth }, async (request, reply) => {
try {
const payload = runSearchSchema.parse(request.body);
const result = await runSearchForUser(getDbPool(), request.authUser!.id, payload);
return reply.code(201).send(result);
} catch (error) {
if (error instanceof z.ZodError) {
return reply.code(400).send({ error: error.issues[0]?.message || 'Invalid research payload.' });
}
request.log.error(error);
return reply.code(500).send({ error: error instanceof Error ? error.message : 'Failed to run research.' });
}
});
app.get('/search-jobs/:jobId', { preHandler: requireAuth }, async (request, reply) => {
const { jobId } = jobParamsSchema.parse(request.params);
const job = await getSearchJobForUser(getDbPool(), request.authUser!.id, jobId);
if (!job) {
return reply.code(404).send({ error: 'Research job not found.' });
}
return { job };
});
app.get('/search-jobs/:jobId/businesses', { preHandler: requireAuth }, async (request, reply) => {
const { jobId } = jobParamsSchema.parse(request.params);
const businesses = await listBusinessesForJobIds(getDbPool(), request.authUser!.id, [jobId]);
return { businesses };
});
app.get('/search-job-results/links', { preHandler: requireAuth }, async (request) => {
const links = await listSearchJobResultLinksForUser(getDbPool(), request.authUser!.id);
return { links };
});
app.get('/businesses', { preHandler: requireAuth }, async (request) => {
const query = (request.query ?? {}) as { jobIds?: string | string[]; jobId?: string };
const rawJobIds = Array.isArray(query.jobIds) ? query.jobIds : typeof query.jobIds === 'string' ? query.jobIds.split(',') : [];
const jobIds = [...rawJobIds, ...(query.jobId ? [query.jobId] : [])].filter(Boolean);
const businesses = jobIds.length > 0
? await listBusinessesForJobIds(getDbPool(), request.authUser!.id, jobIds)
: await listBusinessesForUser(getDbPool(), request.authUser!.id);
return { businesses };
});
};
+192
View File
@@ -0,0 +1,192 @@
type AddressComponent = {
longText?: string;
shortText?: string;
types?: string[];
};
type Place = {
id?: string;
displayName?: { text?: string };
formattedAddress?: string;
location?: { latitude?: number; longitude?: number };
rating?: number;
userRatingCount?: number;
websiteUri?: string;
nationalPhoneNumber?: string;
types?: string[];
addressComponents?: AddressComponent[];
};
type SearchPlacesResponse = {
places: Place[];
nextPageToken?: string;
};
const PLACES_PAGE_SIZE = 20;
const MAX_PLACES_PER_RUN = 60;
const MAX_PLACE_PAGES = Math.ceil(MAX_PLACES_PER_RUN / PLACES_PAGE_SIZE);
function getAddressComponent(components: AddressComponent[] | undefined, type: string, useShort = false) {
if (!components) {
return null;
}
const match = components.find((component) => component.types?.includes(type));
if (!match) {
return null;
}
return useShort ? match.shortText || match.longText || null : match.longText || match.shortText || null;
}
export type BusinessUpsertRecord = {
externalSourceId: string | null;
source: string;
name: string;
address: string | null;
city: string | null;
stateProvince: string | null;
postalCode: string | null;
country: string | null;
phone: string | null;
website: string | null;
rating: number | null;
reviewCount: number | null;
category: string;
latitude: number | null;
longitude: number | null;
metadataJson: Record<string, unknown>;
firstSeenAt: string;
lastSeenAt: string;
updatedAt: string;
};
export async function geocodeLocation(location: string, apiKey: string) {
const url = new URL('https://maps.googleapis.com/maps/api/geocode/json');
url.searchParams.set('address', location);
url.searchParams.set('key', apiKey);
const response = await fetch(url);
const payload = (await response.json()) as {
status?: string;
results?: Array<{ geometry?: { location?: { lat: number; lng: number } } }>;
};
if (!response.ok || payload.status !== 'OK' || !payload.results?.[0]?.geometry?.location) {
throw new Error('Unable to geocode the requested location');
}
return payload.results[0].geometry.location as { lat: number; lng: number };
}
async function searchPlaces(params: {
apiKey: string;
textQuery: string;
lat: number;
lng: number;
radiusKm: number;
pageToken?: string;
}) {
const response = await fetch('https://places.googleapis.com/v1/places:searchText', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Goog-Api-Key': params.apiKey,
'X-Goog-FieldMask': [
'places.id',
'places.displayName',
'places.formattedAddress',
'places.location',
'places.rating',
'places.userRatingCount',
'places.websiteUri',
'places.nationalPhoneNumber',
'places.types',
'places.addressComponents',
'nextPageToken',
].join(','),
},
body: JSON.stringify({
textQuery: params.textQuery,
pageSize: PLACES_PAGE_SIZE,
...(params.pageToken ? { pageToken: params.pageToken } : {}),
locationBias: {
circle: {
center: {
latitude: params.lat,
longitude: params.lng,
},
radius: Math.min(params.radiusKm * 1000, 50000),
},
},
}),
});
const payload = (await response.json()) as {
error?: { message?: string };
places?: Place[];
nextPageToken?: string;
};
if (!response.ok) {
throw new Error(payload.error?.message || 'Places search failed');
}
return {
places: (payload.places || []) as Place[],
nextPageToken: typeof payload.nextPageToken === 'string' ? payload.nextPageToken : undefined,
} as SearchPlacesResponse;
}
export async function collectPlaces(params: { apiKey: string; textQuery: string; lat: number; lng: number; radiusKm: number }) {
const uniquePlaces = new Map<string, Place>();
let nextPageToken: string | undefined;
for (let page = 0; page < MAX_PLACE_PAGES && uniquePlaces.size < MAX_PLACES_PER_RUN; page += 1) {
const response = await searchPlaces({ ...params, pageToken: nextPageToken });
response.places.forEach((place) => {
if (!place.id || uniquePlaces.has(place.id) || uniquePlaces.size >= MAX_PLACES_PER_RUN) {
return;
}
uniquePlaces.set(place.id, place);
});
if (!response.nextPageToken || response.places.length === 0) {
break;
}
nextPageToken = response.nextPageToken;
}
return Array.from(uniquePlaces.values());
}
export function buildBusinessPayload(place: Place, businessType: string): BusinessUpsertRecord {
const now = new Date().toISOString();
return {
externalSourceId: place.id ?? null,
source: 'google_places',
name: place.displayName?.text || 'Unknown business',
address: place.formattedAddress ?? null,
city: getAddressComponent(place.addressComponents, 'locality'),
stateProvince: getAddressComponent(place.addressComponents, 'administrative_area_level_1', true),
postalCode: getAddressComponent(place.addressComponents, 'postal_code'),
country: getAddressComponent(place.addressComponents, 'country', true),
phone: place.nationalPhoneNumber ?? null,
website: place.websiteUri ?? null,
rating: place.rating ?? null,
reviewCount: place.userRatingCount ?? null,
category: businessType,
latitude: place.location?.latitude ?? null,
longitude: place.location?.longitude ?? null,
metadataJson: {
google_types: place.types ?? [],
},
firstSeenAt: now,
lastSeenAt: now,
updatedAt: now,
};
}
+231
View File
@@ -0,0 +1,231 @@
import type { Pool, PoolClient } from 'pg';
import type { BusinessUpsertRecord } from './google-places.js';
import { mapBusinessRow, mapSearchJobRow, type BusinessDto, type BusinessRow, type RunSearchInput, type SearchJobDto, type SearchJobResultLinkDto, type SearchJobRow, } from './types.js';
type DbClient = Pool | PoolClient;
export async function createSearchJob(db: DbClient, userId: string, payload: RunSearchInput) {
const now = new Date().toISOString();
const jobName = payload.name || `${payload.businessType} in ${payload.location}`;
const result = await db.query<SearchJobRow>(
`
insert into public.search_jobs (
user_id, name, city, radius_km, business_type, keywords, status, total_results, started_at, created_at, updated_at
)
values ($1, $2, $3, $4, $5, $6, 'running', 0, $7, $7, $7)
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, jobName, payload.location, payload.radiusKm, payload.businessType, payload.keywords ?? null, now],
);
return mapSearchJobRow(result.rows[0]);
}
export async function updateSearchJobCenter(db: DbClient, jobId: string, lat: number, lng: number) {
await db.query(
`
update public.search_jobs
set search_center_geom = ST_SetSRID(ST_MakePoint($2, $1), 4326)::geography,
updated_at = now()
where id = $3
`,
[lat, lng, jobId],
);
}
export async function completeSearchJob(db: DbClient, jobId: string, totalResults: number) {
const completedAt = new Date().toISOString();
const result = await db.query<SearchJobRow>(
`
update public.search_jobs
set total_results = $2,
status = 'completed',
completed_at = $3,
updated_at = $3
where id = $1
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
`,
[jobId, totalResults, completedAt],
);
return mapSearchJobRow(result.rows[0]);
}
export async function failSearchJob(db: DbClient, jobId: string) {
await db.query(
`
update public.search_jobs
set status = 'failed', updated_at = now()
where id = $1
`,
[jobId],
);
}
export async function upsertBusiness(db: DbClient, userId: string, business: BusinessUpsertRecord) {
const result = await db.query<{ id: string }>(
`
insert into public.businesses (
user_id, external_source_id, source, name, address, city, state_province, postal_code, country,
phone, website, rating, review_count, category, latitude, longitude, geom, metadata_json,
first_seen_at, last_seen_at, updated_at
)
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,
$17::jsonb, $18, $19, $20
)
on conflict (user_id, source, external_source_id)
do update set
name = excluded.name,
address = excluded.address,
city = excluded.city,
state_province = excluded.state_province,
postal_code = excluded.postal_code,
country = excluded.country,
phone = excluded.phone,
website = excluded.website,
rating = excluded.rating,
review_count = excluded.review_count,
category = excluded.category,
latitude = excluded.latitude,
longitude = excluded.longitude,
geom = excluded.geom,
metadata_json = excluded.metadata_json,
last_seen_at = excluded.last_seen_at,
updated_at = excluded.updated_at
returning id
`,
[
userId,
business.externalSourceId,
business.source,
business.name,
business.address,
business.city,
business.stateProvince,
business.postalCode,
business.country,
business.phone,
business.website,
business.rating,
business.reviewCount,
business.category,
business.latitude,
business.longitude,
JSON.stringify(business.metadataJson),
business.firstSeenAt,
business.lastSeenAt,
business.updatedAt,
],
);
return result.rows[0].id;
}
export async function upsertSearchJobResult(
db: DbClient,
input: { userId: string; searchJobId: string; businessId: string; matchedKeywords: string[] | null; rank: number; capturedAt: string },
) {
await db.query(
`
insert into public.search_job_results (user_id, search_job_id, business_id, matched_keywords, rank, captured_at)
values ($1, $2, $3, $4, $5, $6)
on conflict (search_job_id, business_id)
do update set
matched_keywords = excluded.matched_keywords,
rank = excluded.rank,
captured_at = excluded.captured_at
`,
[input.userId, input.searchJobId, input.businessId, input.matchedKeywords, input.rank, input.capturedAt],
);
}
export async function listSearchJobsForUser(db: DbClient, userId: string, limit = 100) {
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
order by created_at desc
limit $2
`,
[userId, limit],
);
return result.rows.map(mapSearchJobRow);
}
export async function getSearchJobForUser(db: DbClient, userId: string, jobId: 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 id = $2
limit 1
`,
[userId, jobId],
);
if (result.rowCount === 0) {
return null;
}
return mapSearchJobRow(result.rows[0]);
}
export async function listBusinessesForUser(db: DbClient, userId: string) {
const result = await db.query<BusinessRow>(
`
select id, user_id, external_source_id, source, name, address, city, state_province, postal_code,
country, phone, website, rating, review_count, category, hours_json, latitude, longitude,
general_info, metadata_json, first_seen_at, last_seen_at, created_at, updated_at
from public.businesses
where user_id = $1
order by created_at desc
`,
[userId],
);
return result.rows.map(mapBusinessRow);
}
export async function listSearchJobResultLinksForUser(db: DbClient, userId: string): Promise<SearchJobResultLinkDto[]> {
const result = await db.query<{ business_id: string; search_job_id: string }>(
`
select business_id, search_job_id
from public.search_job_results
where user_id = $1
`,
[userId],
);
return result.rows.map((row) => ({ businessId: row.business_id, searchJobId: row.search_job_id }));
}
export async function listBusinessesForJobIds(db: DbClient, userId: string, jobIds: string[]): Promise<BusinessDto[]> {
if (jobIds.length === 0) {
return [];
}
const result = await db.query<BusinessRow>(
`
select distinct b.id, b.user_id, b.external_source_id, b.source, b.name, b.address, b.city, b.state_province, b.postal_code,
b.country, b.phone, b.website, b.rating, b.review_count, b.category, b.hours_json, b.latitude, b.longitude,
b.general_info, b.metadata_json, b.first_seen_at, b.last_seen_at, b.created_at, b.updated_at
from public.businesses b
join public.search_job_results r on r.business_id = b.id
where b.user_id = $1 and r.search_job_id = any($2::uuid[])
order by b.created_at desc
`,
[userId, jobIds],
);
return result.rows.map(mapBusinessRow);
}
+65
View File
@@ -0,0 +1,65 @@
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 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
.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,
};
} catch (error) {
await failSearchJob(db, jobId);
throw error;
}
}
+158
View File
@@ -0,0 +1,158 @@
export type SearchJobStatus = 'pending' | 'running' | 'completed' | 'failed' | 'stopped';
export type SearchJobDto = {
id: string;
userId: string;
name: string;
city?: string;
address?: string;
postalCode?: string;
radiusKm: number;
businessType: string;
keywords?: string;
status: SearchJobStatus;
totalResults: number;
startedAt?: string;
completedAt?: string;
createdAt: string;
updatedAt: string;
};
export type BusinessDto = {
id: string;
userId: string;
externalSourceId?: string;
source: string;
name: string;
address?: string;
city?: string;
stateProvince?: string;
postalCode?: string;
country?: string;
phone?: string;
website?: string;
rating?: number;
reviewCount?: number;
category?: string;
hoursJson?: string;
latitude?: number;
longitude?: number;
generalInfo?: string;
metadataJson?: string;
firstSeenAt?: string;
lastSeenAt?: string;
createdAt: string;
updatedAt: string;
};
export type SearchJobResultLinkDto = {
businessId: string;
searchJobId: string;
};
export type RunSearchInput = {
name?: string;
location: string;
radiusKm: number;
businessType: string;
keywords?: string;
};
export type RunSearchResult = {
job: SearchJobDto;
totalResults: number;
};
export type SearchJobRow = {
id: string;
user_id: string;
name: string;
city: string | null;
address: string | null;
postal_code: string | null;
radius_km: number;
business_type: string;
keywords: string | null;
status: SearchJobStatus;
total_results: number;
started_at: string | null;
completed_at: string | null;
created_at: string;
updated_at: string;
};
export type BusinessRow = {
id: string;
user_id: string;
external_source_id: string | null;
source: string;
name: string;
address: string | null;
city: string | null;
state_province: string | null;
postal_code: string | null;
country: string | null;
phone: string | null;
website: string | null;
rating: number | null;
review_count: number | null;
category: string | null;
hours_json: Record<string, unknown> | null;
latitude: number | null;
longitude: number | null;
general_info: string | null;
metadata_json: Record<string, unknown> | null;
first_seen_at: string | null;
last_seen_at: string | null;
created_at: string;
updated_at: string;
};
export function mapSearchJobRow(row: SearchJobRow): SearchJobDto {
return {
id: row.id,
userId: row.user_id,
name: row.name,
city: row.city ?? undefined,
address: row.address ?? undefined,
postalCode: row.postal_code ?? undefined,
radiusKm: Number(row.radius_km),
businessType: row.business_type,
keywords: row.keywords ?? undefined,
status: row.status,
totalResults: row.total_results,
startedAt: row.started_at ?? undefined,
completedAt: row.completed_at ?? undefined,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export function mapBusinessRow(row: BusinessRow): BusinessDto {
return {
id: row.id,
userId: row.user_id,
externalSourceId: row.external_source_id ?? undefined,
source: row.source,
name: row.name,
address: row.address ?? undefined,
city: row.city ?? undefined,
stateProvince: row.state_province ?? undefined,
postalCode: row.postal_code ?? undefined,
country: row.country ?? undefined,
phone: row.phone ?? undefined,
website: row.website ?? undefined,
rating: row.rating ?? undefined,
reviewCount: row.review_count ?? undefined,
category: row.category ?? undefined,
hoursJson: row.hours_json ? JSON.stringify(row.hours_json) : undefined,
latitude: row.latitude ?? undefined,
longitude: row.longitude ?? undefined,
generalInfo: row.general_info ?? undefined,
metadataJson: row.metadata_json ? JSON.stringify(row.metadata_json) : undefined,
firstSeenAt: row.first_seen_at ?? undefined,
lastSeenAt: row.last_seen_at ?? undefined,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
+19
View File
@@ -0,0 +1,19 @@
import 'dotenv/config';
import { getBoss, stopBoss } from './db/boss.js';
import { getEnv } from './config/env.js';
import { registerJobs } from './jobs/register-jobs.js';
const env = getEnv();
const boss = await getBoss();
await registerJobs(boss);
console.log(`Leads4less worker started with pg-boss schema '${env.PG_BOSS_SCHEMA}'`);
const shutdown = async () => {
await stopBoss();
process.exit(0);
};
process.on('SIGINT', () => void shutdown());
process.on('SIGTERM', () => void shutdown());
+14
View File
@@ -0,0 +1,14 @@
export type JobStatus = 'pending' | 'running' | 'completed' | 'failed' | 'stopped';
export interface AppUser {
id: string;
email: string;
displayName: string;
avatarUrl?: string | null;
createdAt: string;
updatedAt: string;
}
export interface SessionUser extends AppUser {
sessionId: string;
}
+92 -90
View File
@@ -1,21 +1,22 @@
import { type ReactElement, type SVGProps, useEffect, useState } from 'react';
import type { User } from '@supabase/supabase-js';
import { APIProvider } from '@vis.gl/react-google-maps';
import { AlertCircle, Briefcase, LogIn, ShieldAlert, UserPlus } from 'lucide-react';
import { Layout } from './components/Layout';
import { SearchSetup } from './components/SearchSetup';
import { Dashboard } from './components/Dashboard';
import { MapView } from './components/MapView';
import { hasSupabaseConfig, supabase } from './lib/supabase';
import type { AppUser } from '../shared/types';
import { getLocalSessionUser, signInWithLocalAuth, signOutWithLocalAuth, signUpWithLocalAuth } from './lib/auth';
import { hasApiConfig } from './lib/api';
const GOOGLE_MAPS_API_KEY = import.meta.env.VITE_GOOGLE_MAPS_PLATFORM_KEY ?? '';
const hasValidMapsKey = Boolean(GOOGLE_MAPS_API_KEY) && GOOGLE_MAPS_API_KEY !== 'YOUR_API_KEY';
export default function App() {
const [user, setUser] = useState<User | null>(null);
const [user, setUser] = useState<AppUser | null>(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<'setup' | 'dashboard' | 'map'>('setup');
const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
const [selectedJobIds, setSelectedJobIds] = useState<string[]>([]);
const [authError, setAuthError] = useState<string | null>(null);
const [authNotice, setAuthNotice] = useState<string | null>(null);
const [isAuthenticating, setIsAuthenticating] = useState(false);
@@ -28,99 +29,96 @@ export default function App() {
let isMounted = true;
const loadSession = async () => {
const { data, error } = await supabase.auth.getSession();
try {
const sessionUser = await getLocalSessionUser();
if (!isMounted) {
return;
if (!isMounted) {
return;
}
setUser(sessionUser);
} catch (error) {
if (!isMounted) {
return;
}
setAuthError(error instanceof Error ? error.message : 'Failed to load session.');
} finally {
if (isMounted) {
setLoading(false);
}
}
if (error) {
setAuthError(error.message);
}
setUser(data.session?.user ?? null);
setLoading(false);
};
void loadSession();
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
if (!isMounted) {
return;
}
setUser(session?.user ?? null);
setLoading(false);
});
return () => {
isMounted = false;
subscription.unsubscribe();
};
}, []);
const handleSelectJob = (jobId: string) => {
setSelectedJobId(jobId);
const handleToggleJobSelection = (jobId: string) => {
setSelectedJobIds((currentJobIds) =>
currentJobIds.includes(jobId) ? currentJobIds.filter((currentJobId) => currentJobId !== jobId) : [...currentJobIds, jobId],
);
};
const handleSelectCreatedJob = (jobId: string) => {
setSelectedJobIds((currentJobIds) => (currentJobIds.includes(jobId) ? currentJobIds : [...currentJobIds, jobId]));
};
const handleShowSelectedOnMap = () => {
if (selectedJobIds.length === 0) {
return;
}
setActiveTab('map');
};
const handleClearSelectedJobs = () => {
setSelectedJobIds([]);
};
const handleLogin = async () => {
setAuthError(null);
setAuthNotice(null);
setIsAuthenticating(true);
if (authMode === 'sign_up') {
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
data: {
full_name: displayName.trim() || undefined,
},
},
});
try {
if (authMode === 'sign_up') {
const nextUser = await signUpWithLocalAuth({
email,
password,
displayName: displayName.trim() || undefined,
});
if (error) {
setAuthError(error.message);
setIsAuthenticating(false);
setUser(nextUser);
setAuthNotice('Account created and signed in.');
return;
}
setAuthNotice(
data.session
? 'Account created and signed in.'
: 'Account created. If email confirmation is enabled, check your inbox before signing in.',
);
const nextUser = await signInWithLocalAuth({
email,
password,
});
setUser(nextUser);
} catch (error) {
setAuthError(error instanceof Error ? error.message : 'Authentication failed.');
} finally {
setIsAuthenticating(false);
return;
}
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
setAuthError(error.message);
setIsAuthenticating(false);
return;
}
setIsAuthenticating(false);
};
const handleLogout = async () => {
const { error } = await supabase.auth.signOut();
if (error) {
setAuthError(error.message);
return;
try {
await signOutWithLocalAuth();
setUser(null);
setSelectedJobIds([]);
setAuthNotice(null);
} catch (error) {
setAuthError(error instanceof Error ? error.message : 'Failed to sign out.');
}
setSelectedJobId(null);
setAuthNotice(null);
};
if (loading) {
@@ -131,33 +129,33 @@ export default function App() {
);
}
if (!hasSupabaseConfig) {
if (!hasApiConfig) {
return (
<ConfigScreen
icon={<ShieldAlert className="h-8 w-8" />}
title="Supabase Config Required"
description="Add your Supabase project URL and anon key before running the app."
title="Local API Config Required"
description="Add your local API base URL before running the app."
steps={[
'Create a Supabase project.',
'Add VITE_SUPABASE_URL to your local environment.',
'Add VITE_SUPABASE_ANON_KEY to your local environment.',
'Start the local Fastify API server.',
'Add VITE_API_BASE_URL to your local environment.',
'Ensure the API can reach your local PostgreSQL database.',
'Restart the Vite dev server after updating env vars.',
]}
footer="The app uses Supabase Auth, Postgres, and Edge Functions now."
footer="The app now uses a local API, PostgreSQL, and local session auth."
/>
);
}
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 Supabase search function."
<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 in Supabase Edge Function secrets.',
'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."
@@ -173,8 +171,8 @@ export default function App() {
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-emerald-100 text-emerald-600">
<Briefcase className="h-8 w-8" />
</div>
<h1 className="mt-6 text-3xl font-bold tracking-tight text-stone-900">Lead Finder</h1>
<p className="mt-2 text-stone-600">Use Supabase email auth to access your lead workspace.</p>
<h1 className="mt-6 text-3xl font-bold tracking-tight text-stone-900">Leads4less</h1>
<p className="mt-2 text-stone-600">Create a local account to access your lead workspace.</p>
</div>
<div className="grid grid-cols-2 gap-2 rounded-xl bg-stone-100 p-1">
@@ -298,17 +296,21 @@ export default function App() {
<Layout
user={user}
activeTab={activeTab}
setActiveTab={(tab) => {
setActiveTab(tab);
if (tab !== 'map') {
setSelectedJobId(null);
}
}}
setActiveTab={setActiveTab}
onLogout={() => void handleLogout()}
>
{activeTab === 'setup' && <SearchSetup user={user} onSelectJob={handleSelectJob} />}
{activeTab === 'setup' && (
<SearchSetup
user={user}
selectedJobIds={selectedJobIds}
onToggleJobSelection={handleToggleJobSelection}
onSelectCreatedJob={handleSelectCreatedJob}
onShowSelectedOnMap={handleShowSelectedOnMap}
onClearSelection={handleClearSelectedJobs}
/>
)}
{activeTab === 'dashboard' && <Dashboard user={user} />}
{activeTab === 'map' && <MapView user={user} jobId={selectedJobId} />}
{activeTab === 'map' && <MapView user={user} jobIds={selectedJobIds} />}
</Layout>
</APIProvider>
);
+3 -3
View File
@@ -1,5 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react';
import type { User } from '@supabase/supabase-js';
import Papa from 'papaparse';
import {
ArrowUpDown,
@@ -16,13 +15,14 @@ import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { listBusinesses, listJobResultLinks, listSearchJobs, type SearchJobResultLink } from '../lib/database';
import type { Business, SearchJob } from '../types';
import type { AppUser } from '../../shared/types';
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
interface DashboardProps {
user: User;
user: AppUser;
}
export function Dashboard({ user }: DashboardProps) {
@@ -184,7 +184,7 @@ export function Dashboard({ user }: DashboardProps) {
<header className="flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
<div>
<h1 className="text-3xl font-bold tracking-tight text-stone-900">Lead Dashboard</h1>
<p className="mt-1 text-stone-600">Browse Supabase-backed search results and export targeted lead lists.</p>
<p className="mt-1 text-stone-600">Browse saved search results from your local workspace and export targeted lead lists.</p>
</div>
<button
onClick={handleExport}
+5 -5
View File
@@ -1,16 +1,16 @@
import React from 'react';
import type { User } from '@supabase/supabase-js';
import { Search, LayoutDashboard, Map as MapIcon, LogOut, Briefcase } from 'lucide-react';
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { getUserAvatarUrl, getUserDisplayName } from '../lib/supabase';
import type { AppUser } from '../../shared/types';
import { getUserAvatarUrl, getUserDisplayName } from '../lib/auth';
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
interface LayoutProps {
user: User;
user: AppUser;
activeTab: 'setup' | 'dashboard' | 'map';
setActiveTab: (tab: 'setup' | 'dashboard' | 'map') => void;
onLogout: () => void;
@@ -22,7 +22,7 @@ export function Layout({ user, activeTab, setActiveTab, onLogout, children }: La
const userAvatarUrl = getUserAvatarUrl(user);
const navigation = [
{ id: 'setup', name: 'Setup', icon: Search },
{ id: 'setup', name: 'Research', icon: Search },
{ id: 'dashboard', name: 'Dashboard', icon: LayoutDashboard },
{ id: 'map', name: 'Map View', icon: MapIcon },
];
@@ -35,7 +35,7 @@ export function Layout({ user, activeTab, setActiveTab, onLogout, children }: La
<div className="bg-emerald-600 p-2 rounded-lg text-white">
<Briefcase className="h-6 w-6" />
</div>
<span className="font-bold text-xl tracking-tight text-stone-900">LeadFinder</span>
<span className="font-bold text-xl tracking-tight text-stone-900">Leads4less</span>
</div>
<nav className="flex-1 px-4 py-4 space-y-1">
+32 -10
View File
@@ -1,21 +1,22 @@
import React, { useEffect, useMemo, useState } from 'react';
import type { User } from '@supabase/supabase-js';
import { Map, AdvancedMarker, InfoWindow, Pin, useMap } from '@vis.gl/react-google-maps';
import { Globe, Loader2, MapPin, Navigation, Phone, Star } from 'lucide-react';
import { listBusinesses, listBusinessesForJob } from '../lib/database';
import { listBusinesses, listBusinessesForJobs } from '../lib/database';
import type { Business } from '../types';
import type { AppUser } from '../../shared/types';
interface MapViewProps {
user: User;
jobId?: string | null;
user: AppUser;
jobIds?: string[] | null;
}
export function MapView({ user, jobId }: MapViewProps) {
export function MapView({ user, jobIds }: MapViewProps) {
const map = useMap();
const [businesses, setBusinesses] = useState<Business[]>([]);
const [selected, setSelected] = useState<Business | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const selectedJobCount = jobIds?.length ?? 0;
useEffect(() => {
const loadBusinesses = async () => {
@@ -23,7 +24,7 @@ export function MapView({ user, jobId }: MapViewProps) {
setError(null);
try {
const nextBusinesses = jobId ? await listBusinessesForJob(user.id, jobId) : await listBusinesses(user.id);
const nextBusinesses = selectedJobCount > 0 ? await listBusinessesForJobs(user.id, jobIds ?? []) : await listBusinesses(user.id);
setBusinesses(nextBusinesses);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load map leads.');
@@ -33,7 +34,7 @@ export function MapView({ user, jobId }: MapViewProps) {
};
void loadBusinesses();
}, [jobId, user.id]);
}, [jobIds, selectedJobCount, user.id]);
useEffect(() => {
if (!selected) {
@@ -96,6 +97,25 @@ export function MapView({ user, jobId }: MapViewProps) {
);
}
if (businesses.length === 0) {
return (
<div className="flex flex-1 items-center justify-center bg-stone-50 p-8">
<div className="w-full max-w-lg rounded-3xl border border-stone-200 bg-white p-8 text-center shadow-sm">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-stone-100 text-stone-500">
<MapPin className="h-6 w-6" />
</div>
<h2 className="mt-4 text-2xl font-bold text-stone-900">No leads to show on the map</h2>
<p className="mt-3 text-sm text-stone-600">
{selectedJobCount > 0
? 'The selected research jobs do not have saved map results yet. Try completed jobs or run the research again.'
: 'No saved leads are available yet. Run a research job to populate the map.'}
</p>
{error && <p className="mt-4 text-sm font-medium text-red-700">{error}</p>}
</div>
</div>
);
}
return (
<div className="relative flex-1 bg-stone-100">
{error && (
@@ -198,10 +218,12 @@ export function MapView({ user, jobId }: MapViewProps) {
<span className="text-stone-500">Selected Lead</span>
<span className="max-w-[120px] truncate font-bold text-stone-900">{selected ? selected.name : 'None'}</span>
</div>
{jobId && (
{selectedJobCount > 0 && (
<div className="mt-2 border-t border-stone-200 pt-2">
<p className="text-[10px] font-bold uppercase tracking-wider text-stone-400">Filtering by Job</p>
<p className="text-xs font-medium text-emerald-700 truncate">Active filter applied</p>
<p className="text-[10px] font-bold uppercase tracking-wider text-stone-400">Filtering by Selection</p>
<p className="text-xs font-medium text-emerald-700 truncate">
{selectedJobCount === 1 ? '1 selected research job' : `${selectedJobCount} selected research jobs`}
</p>
</div>
)}
</div>
+404 -133
View File
@@ -1,33 +1,78 @@
import React, { useCallback, useEffect, useState } from 'react';
import type { User } from '@supabase/supabase-js';
import { AlertCircle, CheckCircle2, History, Loader2, MapPin, Play } from 'lucide-react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
AlertCircle,
CalendarDays,
Check,
CheckCircle2,
CircleOff,
Clock3,
Loader2,
MapPin,
Play,
Search as SearchIcon,
SlidersHorizontal,
} from 'lucide-react';
import { listSearchJobs, runSearch } from '../lib/database';
import type { SearchJob } from '../types';
import type { SearchJob, SearchJobStatus } from '../types';
import type { AppUser } from '../../shared/types';
interface SearchSetupProps {
user: User;
onSelectJob: (jobId: string) => void;
user: AppUser;
selectedJobIds: string[];
onToggleJobSelection: (jobId: string) => void;
onSelectCreatedJob: (jobId: string) => void;
onShowSelectedOnMap: () => void;
onClearSelection: () => void;
}
export function SearchSetup({ user, onSelectJob }: SearchSetupProps) {
type JobStatusFilter = 'all' | SearchJobStatus;
type SortOption = 'newest' | 'oldest' | 'results';
const statusOptions: Array<{ value: JobStatusFilter; label: string }> = [
{ value: 'all', label: 'All statuses' },
{ value: 'pending', label: 'Pending' },
{ value: 'running', label: 'Running' },
{ value: 'completed', label: 'Completed' },
{ value: 'failed', label: 'Failed' },
{ value: 'stopped', label: 'Stopped' },
];
const sortOptions: Array<{ value: SortOption; label: string }> = [
{ value: 'newest', label: 'Newest first' },
{ value: 'oldest', label: 'Oldest first' },
{ value: 'results', label: 'Most results' },
];
export function SearchSetup({
user,
selectedJobIds,
onToggleJobSelection,
onSelectCreatedJob,
onShowSelectedOnMap,
onClearSelection,
}: SearchSetupProps) {
const [name, setName] = useState('');
const [location, setLocation] = useState('');
const [radius, setRadius] = useState(5);
const [businessType, setBusinessType] = useState('');
const [keywords, setKeywords] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<JobStatusFilter>('all');
const [sortOrder, setSortOrder] = useState<SortOption>('newest');
const [isSearching, setIsSearching] = useState(false);
const [isLoadingHistory, setIsLoadingHistory] = useState(true);
const [jobs, setJobs] = useState<SearchJob[]>([]);
const [error, setError] = useState<string | null>(null);
const selectedJobCount = selectedJobIds.length;
const refreshJobs = useCallback(async () => {
setIsLoadingHistory(true);
try {
const nextJobs = await listSearchJobs(user.id, 10);
const nextJobs = await listSearchJobs(user.id, 100);
setJobs(nextJobs);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load search history.');
setError(err instanceof Error ? err.message : 'Failed to load research jobs.');
} finally {
setIsLoadingHistory(false);
}
@@ -37,6 +82,42 @@ export function SearchSetup({ user, onSelectJob }: SearchSetupProps) {
void refreshJobs();
}, [refreshJobs]);
const filteredJobs = useMemo(() => {
const normalizedSearch = searchTerm.trim().toLowerCase();
const nextJobs = jobs.filter((job) => {
const matchesStatus = statusFilter === 'all' || job.status === statusFilter;
if (!matchesStatus) {
return false;
}
if (!normalizedSearch) {
return true;
}
const haystack = [job.name, job.businessType, job.city, job.address, job.keywords]
.filter(Boolean)
.join(' ')
.toLowerCase();
return haystack.includes(normalizedSearch);
});
nextJobs.sort((left, right) => {
if (sortOrder === 'results') {
return right.totalResults - left.totalResults;
}
const leftTime = new Date(left.createdAt).getTime();
const rightTime = new Date(right.createdAt).getTime();
return sortOrder === 'oldest' ? leftTime - rightTime : rightTime - leftTime;
});
return nextJobs;
}, [jobs, searchTerm, sortOrder, statusFilter]);
const handleRunSearch = async (e: React.FormEvent) => {
e.preventDefault();
setIsSearching(true);
@@ -52,165 +133,355 @@ export function SearchSetup({ user, onSelectJob }: SearchSetupProps) {
});
await refreshJobs();
onSelectJob(response.job.id);
onSelectCreatedJob(response.job.id);
} catch (err) {
setError(err instanceof Error ? err.message : 'Search failed.');
setError(err instanceof Error ? err.message : 'Research failed.');
} finally {
setIsSearching(false);
}
};
return (
<div className="flex-1 overflow-y-auto p-8">
<div className="mx-auto max-w-4xl space-y-8">
<header>
<h1 className="text-3xl font-bold text-stone-900">Search Setup</h1>
<p className="mt-2 text-stone-600">Submit a search job to Supabase and review the results in the dashboard or map view.</p>
<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">Research</h1>
<p className="max-w-3xl text-stone-600">
Run new research from the form below, then browse every job in a single grid with filters and quick access to map results.
</p>
</header>
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
<div className="md:col-span-2">
<form onSubmit={handleRunSearch} className="space-y-6 rounded-2xl border border-stone-200 bg-white p-8 shadow-sm">
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-semibold text-stone-700">Location</label>
<div className="relative">
<MapPin className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-stone-400" />
<input
type="text"
required
placeholder="City, address, or zip"
value={location}
onChange={(e) => setLocation(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-stone-50 py-2 pl-10 pr-4 outline-none transition-all focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500"
/>
</div>
</div>
<section className="rounded-3xl border border-stone-200 bg-white p-6 shadow-sm sm:p-8">
<div className="mb-6 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-emerald-600">Research Form</p>
<h2 className="mt-2 text-2xl font-bold text-stone-900">Start a new research run</h2>
<p className="mt-2 max-w-2xl text-sm text-stone-600">
Each run is processed through the local research API, then saved so you can review it later from the dashboard or map view.
</p>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-semibold text-stone-700">Radius (km)</label>
<input
type="number"
min="1"
max="50"
value={radius}
onChange={(e) => setRadius(Number.parseInt(e.target.value, 10) || 1)}
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-2 outline-none transition-all focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-semibold text-stone-700">Business Type</label>
<form onSubmit={handleRunSearch} className="space-y-6">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2 xl:grid-cols-4">
<div className="space-y-2 xl:col-span-2">
<label className="text-sm font-semibold text-stone-700">Location</label>
<div className="relative">
<MapPin className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-stone-400" />
<input
type="text"
required
placeholder="e.g. coffee shop, plumber"
value={businessType}
onChange={(e) => setBusinessType(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-2 outline-none transition-all focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-semibold text-stone-700">Keywords</label>
<input
type="text"
placeholder="e.g. organic, emergency, family-owned"
value={keywords}
onChange={(e) => setKeywords(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-2 outline-none transition-all focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500"
placeholder="City, address, or zip"
value={location}
onChange={(e) => setLocation(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-stone-50 py-3 pl-10 pr-4 outline-none transition-all focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-semibold text-stone-700">Job Name</label>
<label className="text-sm font-semibold text-stone-700">Radius (km)</label>
<input
type="text"
placeholder="Give this search a memorable name"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-2 outline-none transition-all focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500"
type="number"
min="1"
max="50"
value={radius}
onChange={(e) => setRadius(Number.parseInt(e.target.value, 10) || 1)}
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 outline-none transition-all focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500"
/>
</div>
<div className="rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-sm text-stone-600">
Searches now run through a Supabase Edge Function, so the browser no longer writes leads directly into the database.
<div className="space-y-2">
<label className="text-sm font-semibold text-stone-700">Business Type</label>
<input
type="text"
required
placeholder="e.g. coffee shop, plumber"
value={businessType}
onChange={(e) => setBusinessType(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 outline-none transition-all focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500"
/>
</div>
{error && (
<div className="flex items-center gap-3 rounded-xl border border-red-100 bg-red-50 p-4 text-sm text-red-700">
<AlertCircle className="h-5 w-5 flex-shrink-0" />
<span>{error}</span>
</div>
)}
<div className="space-y-2 xl:col-span-2">
<label className="text-sm font-semibold text-stone-700">Keywords</label>
<input
type="text"
placeholder="e.g. organic, emergency, family-owned"
value={keywords}
onChange={(e) => setKeywords(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 outline-none transition-all focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500"
/>
</div>
<button
type="submit"
disabled={isSearching}
className="flex w-full items-center justify-center gap-2 rounded-xl bg-emerald-600 py-3 font-semibold text-white shadow-sm transition-all hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{isSearching ? (
<>
<Loader2 className="h-5 w-5 animate-spin" />
Running Search...
</>
) : (
<>
<Play className="h-5 w-5" />
Run Search
</>
)}
</button>
</form>
</div>
<div className="space-y-4">
<div className="flex items-center gap-2 font-bold text-stone-900">
<History className="h-5 w-5" />
<h2>Recent Searches</h2>
<div className="space-y-2 xl:col-span-2">
<label className="text-sm font-semibold text-stone-700">Research Name</label>
<input
type="text"
placeholder="Give this research a memorable name"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 outline-none transition-all focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500"
/>
</div>
</div>
{isLoadingHistory ? (
<div className="flex items-center gap-2 rounded-xl border border-stone-200 bg-white p-4 text-sm text-stone-500 shadow-sm">
<Loader2 className="h-4 w-4 animate-spin" />
Loading history...
<div className="rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-sm text-stone-600">
Research runs now go through the local API, so the browser no longer writes leads directly into the database.
</div>
{error && (
<div className="flex items-center gap-3 rounded-xl border border-red-100 bg-red-50 p-4 text-sm text-red-700">
<AlertCircle className="h-5 w-5 flex-shrink-0" />
<span>{error}</span>
</div>
) : jobs.length === 0 ? (
<p className="text-sm italic text-stone-500">No search history yet.</p>
) : (
<div className="space-y-3">
{jobs.map((job) => (
)}
<button
type="submit"
disabled={isSearching}
className="flex w-full items-center justify-center gap-2 rounded-xl bg-emerald-600 py-3 font-semibold text-white shadow-sm transition-all hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-50 sm:w-auto sm:px-8"
>
{isSearching ? (
<>
<Loader2 className="h-5 w-5 animate-spin" />
Running Research...
</>
) : (
<>
<Play className="h-5 w-5" />
Run Research
</>
)}
</button>
</form>
</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">Research Jobs</p>
<h2 className="mt-2 text-2xl font-bold text-stone-900">All research runs</h2>
<p className="mt-2 text-sm text-stone-600">
Filter the grid to find specific runs, select the ones you want, then send the full selection to the map.
</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">
{filteredJobs.length} shown of {jobs.length}
</div>
</div>
<div className="space-y-4 rounded-2xl border border-stone-200 bg-white p-4 shadow-sm">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1fr)_220px_220px]">
<label className="space-y-2">
<span className="flex items-center gap-2 text-sm font-semibold text-stone-700">
<SearchIcon className="h-4 w-4 text-stone-400" />
Search
</span>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search by name, type, or location"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 outline-none transition-all focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500"
/>
</label>
<label className="space-y-2">
<span className="flex items-center gap-2 text-sm font-semibold text-stone-700">
<SlidersHorizontal className="h-4 w-4 text-stone-400" />
Status
</span>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as JobStatusFilter)}
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 outline-none transition-all focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500"
>
{statusOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="space-y-2">
<span className="text-sm font-semibold text-stone-700">Sort</span>
<select
value={sortOrder}
onChange={(e) => setSortOrder(e.target.value as SortOption)}
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 outline-none transition-all focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500"
>
{sortOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
</div>
{selectedJobCount > 0 && (
<div className="flex flex-col gap-3 border-t border-stone-100 pt-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm font-semibold text-emerald-900">
{selectedJobCount === 1 ? '1 research job selected' : `${selectedJobCount} research jobs selected`}
</p>
<p className="text-sm text-stone-600">Use the selection action to open all selected jobs together on the map.</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<button
key={job.id}
onClick={() => onSelectJob(job.id)}
className="group w-full space-y-2 rounded-xl border border-stone-200 bg-white p-4 text-left shadow-sm transition-all hover:border-emerald-500 hover:shadow-md"
type="button"
onClick={onShowSelectedOnMap}
className="rounded-xl bg-emerald-600 px-4 py-3 text-sm font-semibold text-white shadow-sm transition-all hover:bg-emerald-700"
>
<div className="flex items-start justify-between gap-2">
<h3 className="truncate text-sm font-bold text-stone-900 group-hover:text-emerald-700">{job.name}</h3>
{job.status === 'completed' ? (
<CheckCircle2 className="h-4 w-4 flex-shrink-0 text-emerald-500" />
) : job.status === 'running' ? (
<Loader2 className="h-4 w-4 flex-shrink-0 animate-spin text-emerald-500" />
) : (
<AlertCircle className="h-4 w-4 flex-shrink-0 text-red-500" />
)}
</div>
<div className="flex items-center gap-2 text-xs text-stone-500">
<MapPin className="h-3 w-3" />
<span className="truncate">{job.city} ({job.radiusKm}km)</span>
</div>
<div className="flex items-center justify-between border-t border-stone-50 pt-2">
<span className="text-xs font-medium text-stone-400">{new Date(job.createdAt).toLocaleDateString()}</span>
<span className="text-xs font-bold text-emerald-600">{job.totalResults} leads</span>
</div>
Show selected on map ({selectedJobCount})
</button>
))}
<button
type="button"
onClick={onClearSelection}
className="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"
>
Clear selection
</button>
</div>
</div>
)}
</div>
</div>
{isLoadingHistory ? (
<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 research jobs...
</div>
) : filteredJobs.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 research jobs match the current filters.</p>
<p className="mt-2 text-sm text-stone-500">Try adjusting the search term, status filter, or sort order.</p>
</div>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{filteredJobs.map((job) => {
const statusMeta = getStatusMeta(job.status);
const isSelected = selectedJobIds.includes(job.id);
return (
<button
type="button"
key={job.id}
aria-pressed={isSelected}
onClick={() => onToggleJobSelection(job.id)}
className={`group flex h-full flex-col rounded-2xl border p-5 text-left shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-md ${
isSelected
? 'border-emerald-400 bg-emerald-50/70 shadow-emerald-100'
: 'border-stone-200 bg-white hover:border-emerald-400'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-lg font-bold text-stone-900 group-hover:text-emerald-700">{job.name}</p>
<p className="mt-1 truncate text-sm text-stone-500">{job.businessType}</p>
</div>
<div className="flex flex-col items-end gap-2">
<span className={statusMeta.badgeClass}>
<statusMeta.icon className={statusMeta.icon === Loader2 ? 'h-3.5 w-3.5 animate-spin' : 'h-3.5 w-3.5'} />
{statusMeta.label}
</span>
<span
className={`inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-semibold ${
isSelected ? 'bg-emerald-600 text-white' : 'bg-stone-100 text-stone-500'
}`}
>
{isSelected ? <Check className="h-3.5 w-3.5" /> : <span className="h-2 w-2 rounded-full bg-current opacity-60"></span>}
{isSelected ? 'Selected' : 'Click to select'}
</span>
</div>
</div>
<div className="mt-5 space-y-3 text-sm text-stone-600">
<div className="flex items-start gap-2">
<MapPin className="mt-0.5 h-4 w-4 flex-shrink-0 text-stone-400" />
<span className="line-clamp-2">{formatLocation(job)}</span>
</div>
<div className="flex items-center gap-2">
<CalendarDays className="h-4 w-4 flex-shrink-0 text-stone-400" />
<span>{new Date(job.createdAt).toLocaleDateString()}</span>
</div>
{job.keywords ? <p className="line-clamp-2 text-stone-500">Keywords: {job.keywords}</p> : null}
</div>
<div className="mt-5 grid grid-cols-2 gap-3 rounded-2xl bg-stone-50 p-4">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-stone-400">Radius</p>
<p className="mt-1 text-base font-bold text-stone-900">{job.radiusKm} km</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-stone-400">Results</p>
<p className="mt-1 text-base font-bold text-stone-900">{job.totalResults}</p>
</div>
</div>
<div className="mt-4 flex items-center justify-between border-t border-stone-100 pt-4 text-sm font-medium text-stone-500">
<span>{job.totalResults === 1 ? '1 lead found' : `${job.totalResults} leads found`}</span>
<span className={isSelected ? 'text-emerald-700' : 'text-stone-400'}>
{isSelected ? 'Included in selection' : 'Available to select'}
</span>
</div>
</button>
);
})}
</div>
)}
</section>
</div>
</div>
);
}
function formatLocation(job: SearchJob) {
const primaryLocation = job.address || job.city || 'Location not available';
return `${primaryLocation} (${job.radiusKm} km radius)`;
}
function getStatusMeta(status: SearchJobStatus) {
switch (status) {
case 'completed':
return {
label: 'Completed',
icon: CheckCircle2,
badgeClass:
'inline-flex items-center gap-1 rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700',
};
case 'running':
return {
label: 'Running',
icon: Loader2,
badgeClass:
'inline-flex items-center gap-1 rounded-full border border-sky-200 bg-sky-50 px-3 py-1 text-xs font-semibold text-sky-700',
};
case 'failed':
return {
label: 'Failed',
icon: AlertCircle,
badgeClass:
'inline-flex items-center gap-1 rounded-full border border-red-200 bg-red-50 px-3 py-1 text-xs font-semibold text-red-700',
};
case 'stopped':
return {
label: 'Stopped',
icon: CircleOff,
badgeClass:
'inline-flex items-center gap-1 rounded-full border border-stone-200 bg-stone-100 px-3 py-1 text-xs font-semibold text-stone-700',
};
case 'pending':
default:
return {
label: 'Pending',
icon: Clock3,
badgeClass:
'inline-flex items-center gap-1 rounded-full border border-amber-200 bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700',
};
}
}
+28
View File
@@ -0,0 +1,28 @@
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? '';
export const hasApiConfig = Boolean(apiBaseUrl);
export async function apiRequest<T>(path: string, init?: RequestInit): Promise<T> {
if (!apiBaseUrl) {
throw new Error('VITE_API_BASE_URL is not configured.');
}
const response = await fetch(`${apiBaseUrl}${path}`, {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(init?.headers ?? {}),
},
...init,
});
const contentType = response.headers.get('content-type') || '';
const payload = contentType.includes('application/json') ? ((await response.json()) as unknown) : null;
if (!response.ok) {
const message = payload && typeof payload === 'object' && 'error' in payload ? String(payload.error) : 'Request failed.';
throw new Error(message);
}
return payload as T;
}
+39
View File
@@ -0,0 +1,39 @@
import type { AppUser, SessionUser } from '../../shared/types';
import { apiRequest } from './api';
export function getUserDisplayName(user: AppUser | SessionUser | null): string {
return user?.displayName || user?.email || 'User';
}
export function getUserAvatarUrl(user: AppUser | SessionUser | null): string | null {
return user?.avatarUrl || null;
}
export async function getLocalSessionUser() {
const response = await apiRequest<{ user: SessionUser | null }>('/auth/me');
return response.user;
}
export async function signUpWithLocalAuth(payload: { email: string; password: string; displayName?: string }) {
const response = await apiRequest<{ user: SessionUser }>('/auth/signup', {
method: 'POST',
body: JSON.stringify(payload),
});
return response.user;
}
export async function signInWithLocalAuth(payload: { email: string; password: string }) {
const response = await apiRequest<{ user: SessionUser }>('/auth/login', {
method: 'POST',
body: JSON.stringify(payload),
});
return response.user;
}
export async function signOutWithLocalAuth() {
await apiRequest<{ success: boolean }>('/auth/logout', {
method: 'POST',
});
}
+29 -165
View File
@@ -1,51 +1,6 @@
import { supabase } from './supabase';
import { apiRequest } from './api';
import type { Business, SearchJob } from '../types';
type SearchJobRow = {
id: string;
user_id: string;
name: string;
city: string | null;
address: string | null;
postal_code: string | null;
radius_km: number;
business_type: string;
keywords: string | null;
status: SearchJob['status'];
total_results: number;
started_at: string | null;
completed_at: string | null;
created_at: string;
updated_at: string;
};
type BusinessRow = {
id: string;
user_id: string;
external_source_id: string | null;
source: string;
name: string;
address: string | null;
city: string | null;
state_province: string | null;
postal_code: string | null;
country: string | null;
phone: string | null;
website: string | null;
rating: number | null;
review_count: number | null;
category: string | null;
hours_json: Record<string, unknown> | null;
latitude: number | null;
longitude: number | null;
general_info: string | null;
metadata_json: Record<string, unknown> | null;
first_seen_at: string | null;
last_seen_at: string | null;
created_at: string;
updated_at: string;
};
export type SearchJobResultLink = {
businessId: string;
searchJobId: string;
@@ -64,133 +19,42 @@ export type RunSearchResponse = {
totalResults: number;
};
function mapSearchJob(row: SearchJobRow): SearchJob {
return {
id: row.id,
userId: row.user_id,
name: row.name,
city: row.city ?? undefined,
address: row.address ?? undefined,
postalCode: row.postal_code ?? undefined,
radiusKm: row.radius_km,
businessType: row.business_type,
keywords: row.keywords ?? undefined,
status: row.status,
totalResults: row.total_results,
startedAt: row.started_at ?? undefined,
completedAt: row.completed_at ?? undefined,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
export async function listSearchJobs(userIdOrMax?: string | number, max = 10): Promise<SearchJob[]> {
const limit = typeof userIdOrMax === 'number' ? userIdOrMax : max;
const response = await apiRequest<{ jobs: SearchJob[] }>(`/search-jobs?limit=${limit}`);
return response.jobs;
}
function mapBusiness(row: BusinessRow): Business {
return {
id: row.id,
userId: row.user_id,
externalSourceId: row.external_source_id ?? undefined,
source: row.source,
name: row.name,
address: row.address ?? undefined,
city: row.city ?? undefined,
stateProvince: row.state_province ?? undefined,
postalCode: row.postal_code ?? undefined,
country: row.country ?? undefined,
phone: row.phone ?? undefined,
website: row.website ?? undefined,
rating: row.rating ?? undefined,
reviewCount: row.review_count ?? undefined,
category: row.category ?? undefined,
hoursJson: row.hours_json ? JSON.stringify(row.hours_json) : undefined,
latitude: row.latitude ?? undefined,
longitude: row.longitude ?? undefined,
generalInfo: row.general_info ?? undefined,
metadataJson: row.metadata_json ? JSON.stringify(row.metadata_json) : undefined,
firstSeenAt: row.first_seen_at ?? undefined,
lastSeenAt: row.last_seen_at ?? undefined,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
export async function listBusinesses(_userId?: string): Promise<Business[]> {
const response = await apiRequest<{ businesses: Business[] }>('/businesses');
return response.businesses;
}
function ensureData<T>(data: T | null, error: { message: string } | null, context: string): T {
if (error) {
throw new Error(`${context}: ${error.message}`);
export async function listJobResultLinks(_userId?: string): Promise<SearchJobResultLink[]> {
const response = await apiRequest<{ links: SearchJobResultLink[] }>('/search-job-results/links');
return response.links;
}
export async function listBusinessesForJob(userIdOrJobId: string, maybeJobId?: string): Promise<Business[]> {
const jobId = maybeJobId ?? userIdOrJobId;
const response = await apiRequest<{ businesses: Business[] }>(`/search-jobs/${jobId}/businesses`);
return response.businesses;
}
export async function listBusinessesForJobs(userIdOrJobIds: string | string[], maybeJobIds?: string[]): Promise<Business[]> {
const jobIds = Array.isArray(userIdOrJobIds) ? userIdOrJobIds : maybeJobIds ?? [];
if (jobIds.length === 0) {
return [];
}
if (data === null) {
throw new Error(`${context}: no data returned`);
}
return data;
}
export async function listSearchJobs(userId: string, max = 10): Promise<SearchJob[]> {
const { data, error } = await supabase
.from('search_jobs')
.select('*')
.eq('user_id', userId)
.order('created_at', { ascending: false })
.limit(max);
const rows = ensureData(data as SearchJobRow[] | null, error, 'Failed to load search jobs');
return rows.map(mapSearchJob);
}
export async function listBusinesses(userId: string): Promise<Business[]> {
const { data, error } = await supabase
.from('businesses')
.select('*')
.eq('user_id', userId)
.order('created_at', { ascending: false });
const rows = ensureData(data as BusinessRow[] | null, error, 'Failed to load businesses');
return rows.map(mapBusiness);
}
export async function listJobResultLinks(userId: string): Promise<SearchJobResultLink[]> {
const { data, error } = await supabase
.from('search_job_results')
.select('business_id, search_job_id')
.eq('user_id', userId);
const rows = ensureData(data as Array<{ business_id: string; search_job_id: string }> | null, error, 'Failed to load job links');
return rows.map((row) => ({ businessId: row.business_id, searchJobId: row.search_job_id }));
}
export async function listBusinessesForJob(userId: string, jobId: string): Promise<Business[]> {
const { data, error } = await supabase
.from('search_job_results')
.select('business:businesses(*)')
.eq('user_id', userId)
.eq('search_job_id', jobId);
const rows = ensureData(
data as Array<{ business: BusinessRow[] | null }> | null,
error,
'Failed to load businesses for job',
);
return rows.flatMap((row) => {
const business = row.business?.[0];
if (!business) {
return [];
}
return [mapBusiness(business)];
});
const response = await apiRequest<{ businesses: Business[] }>(`/businesses?jobIds=${encodeURIComponent(jobIds.join(','))}`);
return response.businesses;
}
export async function runSearch(payload: RunSearchPayload): Promise<RunSearchResponse> {
const { data, error } = await supabase.functions.invoke('run-search', {
body: payload,
return apiRequest<RunSearchResponse>('/search-jobs', {
method: 'POST',
body: JSON.stringify(payload),
});
const response = ensureData(data as { job: SearchJobRow; totalResults: number } | null, error, 'Search failed');
return {
job: mapSearchJob(response.job),
totalResults: response.totalResults,
};
}
-34
View File
@@ -1,34 +0,0 @@
import { createClient, type User } from '@supabase/supabase-js';
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL ?? '';
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY ?? '';
export const hasSupabaseConfig = Boolean(supabaseUrl && supabaseAnonKey);
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
persistSession: true,
autoRefreshToken: true,
detectSessionInUrl: true,
},
});
export function getUserDisplayName(user: User | null): string {
if (!user) {
return 'User';
}
const fullName = typeof user.user_metadata?.full_name === 'string' ? user.user_metadata.full_name : null;
const name = typeof user.user_metadata?.name === 'string' ? user.user_metadata.name : null;
const emailName = user.email ? user.email.split('@')[0] : null;
return fullName || name || emailName || user.email || 'User';
}
export function getUserAvatarUrl(user: User | null): string | null {
if (!user) {
return null;
}
return typeof user.user_metadata?.avatar_url === 'string' ? user.user_metadata.avatar_url : null;
}
+1 -2
View File
@@ -1,8 +1,7 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_SUPABASE_URL: string;
readonly VITE_SUPABASE_ANON_KEY: string;
readonly VITE_API_BASE_URL: string;
readonly VITE_GOOGLE_MAPS_PLATFORM_KEY: string;
}
-367
View File
@@ -1,367 +0,0 @@
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4';
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};
type SearchRequest = {
name?: string;
location: string;
radiusKm: number;
businessType: string;
keywords?: string;
};
type AddressComponent = {
longText?: string;
shortText?: string;
types?: string[];
};
type Place = {
id?: string;
displayName?: { text?: string };
formattedAddress?: string;
location?: { latitude?: number; longitude?: number };
rating?: number;
userRatingCount?: number;
websiteUri?: string;
nationalPhoneNumber?: string;
types?: string[];
addressComponents?: AddressComponent[];
};
function jsonResponse(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: {
...corsHeaders,
'Content-Type': 'application/json',
},
});
}
function assertEnv(name: string): string {
const value = Deno.env.get(name);
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
function parseRequest(body: unknown): SearchRequest {
if (!body || typeof body !== 'object') {
throw new Error('Invalid request body');
}
const payload = body as Record<string, unknown>;
const location = typeof payload.location === 'string' ? payload.location.trim() : '';
const businessType = typeof payload.businessType === 'string' ? payload.businessType.trim() : '';
const radiusKm = typeof payload.radiusKm === 'number' ? payload.radiusKm : Number(payload.radiusKm);
const name = typeof payload.name === 'string' ? payload.name.trim() : undefined;
const keywords = typeof payload.keywords === 'string' ? payload.keywords.trim() : undefined;
if (!location || !businessType || Number.isNaN(radiusKm) || radiusKm <= 0) {
throw new Error('location, radiusKm, and businessType are required');
}
return {
name,
location,
radiusKm,
businessType,
keywords,
};
}
async function geocodeLocation(location: string, apiKey: string) {
const url = new URL('https://maps.googleapis.com/maps/api/geocode/json');
url.searchParams.set('address', location);
url.searchParams.set('key', apiKey);
const response = await fetch(url);
const payload = await response.json();
if (!response.ok || payload.status !== 'OK' || !payload.results?.[0]?.geometry?.location) {
throw new Error('Unable to geocode the requested location');
}
return payload.results[0].geometry.location as { lat: number; lng: number };
}
async function searchPlaces(params: {
apiKey: string;
textQuery: string;
lat: number;
lng: number;
radiusKm: number;
}) {
const response = await fetch('https://places.googleapis.com/v1/places:searchText', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Goog-Api-Key': params.apiKey,
'X-Goog-FieldMask': [
'places.id',
'places.displayName',
'places.formattedAddress',
'places.location',
'places.rating',
'places.userRatingCount',
'places.websiteUri',
'places.nationalPhoneNumber',
'places.types',
'places.addressComponents',
].join(','),
},
body: JSON.stringify({
textQuery: params.textQuery,
pageSize: 20,
locationBias: {
circle: {
center: {
latitude: params.lat,
longitude: params.lng,
},
radius: Math.min(params.radiusKm * 1000, 50000),
},
},
}),
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error?.message || 'Places search failed');
}
return (payload.places || []) as Place[];
}
function getAddressComponent(components: AddressComponent[] | undefined, type: string, useShort = false) {
if (!components) {
return null;
}
const match = components.find((component) => component.types?.includes(type));
if (!match) {
return null;
}
return useShort ? match.shortText || match.longText || null : match.longText || match.shortText || null;
}
function buildBusinessPayload(place: Place, userId: string, businessType: string) {
const city = getAddressComponent(place.addressComponents, 'locality');
const stateProvince = getAddressComponent(place.addressComponents, 'administrative_area_level_1', true);
const postalCode = getAddressComponent(place.addressComponents, 'postal_code');
const country = getAddressComponent(place.addressComponents, 'country', true);
const now = new Date().toISOString();
return {
user_id: userId,
external_source_id: place.id ?? null,
source: 'google_places',
name: place.displayName?.text || 'Unknown business',
address: place.formattedAddress ?? null,
city,
state_province: stateProvince,
postal_code: postalCode,
country,
phone: place.nationalPhoneNumber ?? null,
website: place.websiteUri ?? null,
rating: place.rating ?? null,
review_count: place.userRatingCount ?? null,
category: businessType,
latitude: place.location?.latitude ?? null,
longitude: place.location?.longitude ?? null,
metadata_json: {
google_types: place.types ?? [],
},
first_seen_at: now,
last_seen_at: now,
updated_at: now,
};
}
Deno.serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders });
}
let jobId: string | null = null;
try {
const supabaseUrl = assertEnv('SUPABASE_URL');
const supabaseAnonKey = assertEnv('SUPABASE_ANON_KEY');
const supabaseServiceRoleKey = assertEnv('SUPABASE_SERVICE_ROLE_KEY');
const googleMapsServerKey = assertEnv('GOOGLE_MAPS_SERVER_KEY');
const authHeader = req.headers.get('Authorization');
if (!authHeader) {
return jsonResponse({ error: 'Missing Authorization header' }, 401);
}
const authClient = createClient(supabaseUrl, supabaseAnonKey, {
global: {
headers: {
Authorization: authHeader,
},
},
});
const {
data: { user },
error: authError,
} = await authClient.auth.getUser();
if (authError || !user) {
return jsonResponse({ error: authError?.message || 'Unauthorized' }, 401);
}
const serviceClient = createClient(supabaseUrl, supabaseServiceRoleKey, {
auth: {
persistSession: false,
autoRefreshToken: false,
},
});
const payload = parseRequest(await req.json());
const now = new Date().toISOString();
const jobName = payload.name || `${payload.businessType} in ${payload.location}`;
const { data: createdJob, error: createJobError } = await serviceClient
.from('search_jobs')
.insert({
user_id: user.id,
name: jobName,
city: payload.location,
radius_km: payload.radiusKm,
business_type: payload.businessType,
keywords: payload.keywords ?? null,
status: 'running',
total_results: 0,
started_at: now,
created_at: now,
updated_at: now,
})
.select('*')
.single();
if (createJobError || !createdJob) {
throw new Error(createJobError?.message || 'Failed to create search job');
}
jobId = createdJob.id;
const geocoded = await geocodeLocation(payload.location, googleMapsServerKey);
const places = await searchPlaces({
apiKey: googleMapsServerKey,
textQuery: [payload.businessType, payload.keywords].filter(Boolean).join(' '),
lat: geocoded.lat,
lng: geocoded.lng,
radiusKm: payload.radiusKm,
});
const matchedKeywords = payload.keywords
? payload.keywords
.split(',')
.map((keyword) => keyword.trim())
.filter(Boolean)
: [];
let totalResults = 0;
for (const [index, place] of places.entries()) {
if (!place.id || !place.displayName?.text) {
continue;
}
const businessPayload = buildBusinessPayload(place, user.id, payload.businessType);
const { data: business, error: businessError } = await serviceClient
.from('businesses')
.upsert(businessPayload, {
onConflict: 'user_id,source,external_source_id',
})
.select('id')
.single();
if (businessError || !business) {
throw new Error(businessError?.message || 'Failed to upsert business');
}
const { error: resultError } = await serviceClient.from('search_job_results').upsert(
{
user_id: user.id,
search_job_id: jobId,
business_id: business.id,
matched_keywords: matchedKeywords.length > 0 ? matchedKeywords : null,
rank: index + 1,
captured_at: new Date().toISOString(),
},
{
onConflict: 'search_job_id,business_id',
},
);
if (resultError) {
throw new Error(resultError.message);
}
totalResults += 1;
}
const completedAt = new Date().toISOString();
const { data: completedJob, error: completeJobError } = await serviceClient
.from('search_jobs')
.update({
total_results: totalResults,
status: 'completed',
completed_at: completedAt,
updated_at: completedAt,
})
.eq('id', jobId)
.select('*')
.single();
if (completeJobError || !completedJob) {
throw new Error(completeJobError?.message || 'Failed to finalize search job');
}
return jsonResponse({
job: completedJob,
totalResults,
});
} catch (error) {
if (jobId) {
try {
const supabaseUrl = assertEnv('SUPABASE_URL');
const supabaseServiceRoleKey = assertEnv('SUPABASE_SERVICE_ROLE_KEY');
const serviceClient = createClient(supabaseUrl, supabaseServiceRoleKey, {
auth: {
persistSession: false,
autoRefreshToken: false,
},
});
await serviceClient
.from('search_jobs')
.update({
status: 'failed',
updated_at: new Date().toISOString(),
})
.eq('id', jobId);
} catch (_updateError) {
// Ignore secondary failure while surfacing the primary error.
}
}
const message = error instanceof Error ? error.message : 'Unexpected error';
return jsonResponse({ error: message }, 500);
}
});
-165
View File
@@ -1,165 +0,0 @@
create extension if not exists pgcrypto;
create or replace function public.set_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at = now();
return new;
end;
$$;
create table if not exists public.search_jobs (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users (id) on delete cascade,
name text not null,
city text,
address text,
postal_code text,
radius_km numeric not null,
business_type text not null,
keywords text,
status text not null check (status in ('pending', 'running', 'completed', 'failed', 'stopped')),
total_results integer not null default 0,
cancel_requested boolean not null default false,
started_at timestamptz,
completed_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table if not exists public.businesses (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users (id) on delete cascade,
external_source_id text,
source text not null,
name text not null,
address text,
city text,
state_province text,
postal_code text,
country text,
phone text,
website text,
rating numeric,
review_count integer,
category text,
hours_json jsonb,
latitude double precision,
longitude double precision,
general_info text,
metadata_json jsonb,
first_seen_at timestamptz,
last_seen_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint businesses_user_source_external_source_key unique (user_id, source, external_source_id)
);
create table if not exists public.search_job_results (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users (id) on delete cascade,
search_job_id uuid not null references public.search_jobs (id) on delete cascade,
business_id uuid not null references public.businesses (id) on delete cascade,
matched_keywords text[],
rank integer,
captured_at timestamptz not null default now(),
constraint search_job_results_job_business_key unique (search_job_id, business_id)
);
create index if not exists search_jobs_user_created_at_idx on public.search_jobs (user_id, created_at desc);
create index if not exists businesses_user_created_at_idx on public.businesses (user_id, created_at desc);
create index if not exists search_job_results_user_job_idx on public.search_job_results (user_id, search_job_id);
drop trigger if exists set_search_jobs_updated_at on public.search_jobs;
create trigger set_search_jobs_updated_at
before update on public.search_jobs
for each row
execute function public.set_updated_at();
drop trigger if exists set_businesses_updated_at on public.businesses;
create trigger set_businesses_updated_at
before update on public.businesses
for each row
execute function public.set_updated_at();
alter table public.search_jobs enable row level security;
alter table public.businesses enable row level security;
alter table public.search_job_results enable row level security;
drop policy if exists "Users can read their search jobs" on public.search_jobs;
drop policy if exists "Users can insert their search jobs" on public.search_jobs;
drop policy if exists "Users can update their search jobs" on public.search_jobs;
drop policy if exists "Users can delete their search jobs" on public.search_jobs;
drop policy if exists "Users can read their businesses" on public.businesses;
drop policy if exists "Users can insert their businesses" on public.businesses;
drop policy if exists "Users can update their businesses" on public.businesses;
drop policy if exists "Users can delete their businesses" on public.businesses;
drop policy if exists "Users can read their search job results" on public.search_job_results;
drop policy if exists "Users can insert their search job results" on public.search_job_results;
drop policy if exists "Users can update their search job results" on public.search_job_results;
drop policy if exists "Users can delete their search job results" on public.search_job_results;
create policy "Users can read their search jobs"
on public.search_jobs
for select
using (auth.uid() = user_id);
create policy "Users can insert their search jobs"
on public.search_jobs
for insert
with check (auth.uid() = user_id);
create policy "Users can update their search jobs"
on public.search_jobs
for update
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
create policy "Users can delete their search jobs"
on public.search_jobs
for delete
using (auth.uid() = user_id);
create policy "Users can read their businesses"
on public.businesses
for select
using (auth.uid() = user_id);
create policy "Users can insert their businesses"
on public.businesses
for insert
with check (auth.uid() = user_id);
create policy "Users can update their businesses"
on public.businesses
for update
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
create policy "Users can delete their businesses"
on public.businesses
for delete
using (auth.uid() = user_id);
create policy "Users can read their search job results"
on public.search_job_results
for select
using (auth.uid() = user_id);
create policy "Users can insert their search job results"
on public.search_job_results
for insert
with check (auth.uid() = user_id);
create policy "Users can update their search job results"
on public.search_job_results
for update
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
create policy "Users can delete their search job results"
on public.search_job_results
for delete
using (auth.uid() = user_id);
+1 -1
View File
@@ -23,5 +23,5 @@
"allowImportingTsExtensions": true,
"noEmit": true
},
"include": ["src", "vite.config.ts"]
"include": ["src", "shared", "vite.config.ts"]
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"types": ["node"],
"rootDir": ".",
"outDir": "dist-server"
},
"include": ["server/**/*.ts", "db/scripts/**/*.ts", "shared/**/*.ts"]
}