feat: add Supabase-backed local lead finder app
This commit is contained in:
@@ -0,0 +1,367 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user