Public Access
1
0
Files
leads4less/supabase/functions/run-search/index.ts
T
2026-03-26 22:55:43 +00:00

368 lines
10 KiB
TypeScript

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);
}
});