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
+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,
};
}