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