368 lines
10 KiB
TypeScript
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);
|
|
}
|
|
});
|