308 lines
12 KiB
TypeScript
308 lines
12 KiB
TypeScript
import { serve } from "https://deno.land/std@0.168.0/http/server.ts"
|
||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2"
|
||
|
||
const OPENAI_API_KEY = Deno.env.get('OPENAI_API_KEY')
|
||
const GOOGLE_MAPS_API_KEY = Deno.env.get('GOOGLE_MAPS_API_KEY')
|
||
const SUPABASE_URL = Deno.env.get('SUPABASE_URL')
|
||
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')
|
||
|
||
const corsHeaders = {
|
||
'Access-Control-Allow-Origin': '*',
|
||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||
}
|
||
|
||
/**
|
||
* Normalize place names for consistent cache lookups.
|
||
* Handles Turkish characters, accents, and spelling variations.
|
||
*/
|
||
function normalizePlaceName(name: string): string {
|
||
return name
|
||
.toLowerCase()
|
||
.trim()
|
||
// Normalize Turkish characters to ASCII equivalents
|
||
.replace(/ğ/g, 'g')
|
||
.replace(/ü/g, 'u')
|
||
.replace(/ş/g, 's')
|
||
.replace(/ı/g, 'i')
|
||
.replace(/ö/g, 'o')
|
||
.replace(/ç/g, 'c')
|
||
// Also handle uppercase Turkish characters
|
||
.replace(/Ğ/g, 'g')
|
||
.replace(/Ü/g, 'u')
|
||
.replace(/Ş/g, 's')
|
||
.replace(/İ/g, 'i')
|
||
.replace(/Ö/g, 'o')
|
||
.replace(/Ç/g, 'c')
|
||
// Remove extra spaces
|
||
.replace(/\s+/g, ' ')
|
||
// Normalize common suffix variations (preserve them but ensure consistent spacing)
|
||
.replace(/\s*(open air museum|underground city|valley|village|castle|church)\s*$/i, (match) => ' ' + match.trim().toLowerCase())
|
||
}
|
||
|
||
/**
|
||
* Generate a mock itinerary when OpenAI is unavailable.
|
||
*/
|
||
function generateMockItinerary(startDate: string, endDate: string, interests: string[]) {
|
||
const start = new Date(startDate);
|
||
const end = new Date(endDate);
|
||
const diffTime = Math.abs(end.getTime() - start.getTime());
|
||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
|
||
const numDays = Math.min(diffDays, 14);
|
||
|
||
const pool = [
|
||
{ place_name: "Göreme Open Air Museum", category: "museum", duration: 120, desc: "UNESCO World Heritage site with rock-cut churches." },
|
||
{ place_name: "Uçhisar Castle", category: "landmark", duration: 90, desc: "The highest point in Cappadocia with panoramic views." },
|
||
{ place_name: "Pasabag (Monks Valley)", category: "nature", duration: 60, desc: "Famous fairy chimneys with multiple caps." },
|
||
{ place_name: "Devrent Valley (Imagination Valley)", category: "nature", duration: 45, desc: "Unique rock formations resembling animals." },
|
||
{ place_name: "Kaymakli Underground City", category: "history", duration: 90, desc: "Ancient multi-level underground city." },
|
||
{ place_name: "Derinkuyu Underground City", category: "history", duration: 120, desc: "The deepest underground city in the region." },
|
||
{ place_name: "Ihlara Valley", category: "nature", duration: 180, desc: "A stunning canyon with a river and rock-cut churches." },
|
||
{ place_name: "Selime Monastery", category: "history", duration: 60, desc: "The largest religious structure in Cappadocia." },
|
||
{ place_name: "Pigeon Valley", category: "nature", duration: 90, desc: "Valley named after the countless man-made dovecotes." },
|
||
{ place_name: "Love Valley", category: "nature", duration: 60, desc: "Famous for its phallic-shaped fairy chimneys." },
|
||
{ place_name: "Avanos Pottery Workshop", category: "culture", duration: 60, desc: "Traditional pottery making in the Red River town." },
|
||
{ place_name: "Zelve Open Air Museum", category: "museum", duration: 120, desc: "An abandoned cave town with ancient churches." },
|
||
{ place_name: "Ortahisar Castle", category: "landmark", duration: 60, desc: "A massive rock formation used as a fortress." },
|
||
{ place_name: "Rose Valley Sunset Hike", category: "nature", duration: 120, desc: "Beautiful valley that turns pink at sunset." },
|
||
{ place_name: "Red Valley", category: "nature", duration: 90, desc: "Stunning valley with sharp ridges and red hues." },
|
||
{ place_name: "Çavuşin Village", category: "culture", duration: 60, desc: "An old Greek village with a massive rock castle." },
|
||
];
|
||
|
||
const days = [];
|
||
for (let i = 1; i <= numDays; i++) {
|
||
const dailyItems = [];
|
||
|
||
// Always add Hot Air Balloon on Day 1 or 2 if duration is 120
|
||
if (i <= 2) {
|
||
dailyItems.push({
|
||
place_name: "Hot Air Balloon Flight",
|
||
category: "activity",
|
||
estimated_duration_minutes: 180,
|
||
description: "Breathtaking sunrise flight over the fairy chimneys."
|
||
});
|
||
}
|
||
|
||
// Pick 3-4 random items from the pool
|
||
const shuffled = [...pool].sort(() => 0.5 - Math.random());
|
||
const selected = shuffled.slice(0, 3);
|
||
|
||
selected.forEach(item => {
|
||
dailyItems.push({
|
||
place_name: item.place_name,
|
||
category: item.category,
|
||
estimated_duration_minutes: item.duration,
|
||
description: item.desc
|
||
});
|
||
});
|
||
|
||
days.push({
|
||
day: i,
|
||
items: dailyItems
|
||
});
|
||
}
|
||
|
||
return { days };
|
||
}
|
||
|
||
serve(async (req) => {
|
||
if (req.method === 'OPTIONS') {
|
||
return new Response('ok', { headers: corsHeaders })
|
||
}
|
||
|
||
try {
|
||
const { startDate, endDate, interests, dailySchedule, preferences } = await req.json()
|
||
|
||
// Initialize Supabase client with service role
|
||
const supabase = createClient(
|
||
SUPABASE_URL!,
|
||
SUPABASE_SERVICE_ROLE_KEY!
|
||
)
|
||
|
||
let itinerary;
|
||
|
||
// 1. Call OpenAI to generate itinerary (if API key exists)
|
||
if (OPENAI_API_KEY && OPENAI_API_KEY !== 'PASTE_YOUR_OPENAI_API_KEY_HERE') {
|
||
try {
|
||
const systemPrompt = `You are an expert travel planner for Cappadocia, Turkey.
|
||
Generate a personalized travel itinerary based on user preferences.
|
||
Allowed regions: Göreme, Uçhisar, Ürgüp, Avanos, Ortahisar, Çavuşin, Derinkuyu, Kaymaklı.
|
||
Rules:
|
||
- Balloon rides must be at sunrise (05:00 - 08:00) on Day 1 or 2.
|
||
- Sunset valley visits after 17:30.
|
||
- Max 5 places per day.
|
||
- No duplicate places.
|
||
- Only return JSON in the specified format.
|
||
- Interests: ${interests.join(', ')}.
|
||
- Daily Schedule: ${dailySchedule}.
|
||
|
||
Response Format:
|
||
{
|
||
"days": [
|
||
{
|
||
"day": 1,
|
||
"items": [
|
||
{
|
||
"place_name": "Göreme Open Air Museum",
|
||
"category": "museum",
|
||
"estimated_duration_minutes": 120,
|
||
"description": "UNESCO World Heritage site with rock-cut churches."
|
||
}
|
||
]
|
||
}
|
||
]
|
||
}`
|
||
|
||
const userPrompt = `Trip Dates: ${startDate} to ${endDate}. Preferences: ${preferences}`
|
||
|
||
const openaiRes = await fetch('https://api.openai.com/v1/chat/completions', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${OPENAI_API_KEY}`,
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
model: 'gpt-4o-mini',
|
||
messages: [
|
||
{ role: 'system', content: systemPrompt },
|
||
{ role: 'user', content: userPrompt }
|
||
],
|
||
temperature: 0.3,
|
||
response_format: { type: 'json_object' }
|
||
}),
|
||
})
|
||
|
||
if (!openaiRes.ok) {
|
||
throw new Error(`OpenAI API error: ${openaiRes.statusText}`);
|
||
}
|
||
|
||
const openaiData = await openaiRes.json()
|
||
itinerary = JSON.parse(openaiData.choices[0].message.content)
|
||
} catch (err) {
|
||
console.error('OpenAI failed, falling back to mock:', err)
|
||
itinerary = generateMockItinerary(startDate, endDate, interests)
|
||
}
|
||
} else {
|
||
console.log('No OpenAI API key found, using mock itinerary')
|
||
itinerary = generateMockItinerary(startDate, endDate, interests)
|
||
}
|
||
|
||
// 2. Verify places with Google Places API (with cache layer)
|
||
const verifiedDays = []
|
||
for (const day of itinerary.days) {
|
||
const verifiedItems = []
|
||
for (const item of day.items) {
|
||
// Normalize place name for cache lookup using robust normalization
|
||
const normalizedName = normalizePlaceName(item.place_name)
|
||
|
||
// Check cache first
|
||
const { data: cachedPlace, error: cacheError } = await supabase
|
||
.from('places_cache')
|
||
.select('*')
|
||
.eq('place_name_normalized', normalizedName)
|
||
.limit(1)
|
||
.maybeSingle()
|
||
|
||
if (cachedPlace && !cacheError) {
|
||
// Cache HIT - skipping Google API call
|
||
console.log(`Cache HIT for "${item.place_name}" (normalized: "${normalizedName}") - skipping Google API call`)
|
||
verifiedItems.push({
|
||
...item,
|
||
place_id: cachedPlace.place_id,
|
||
name: cachedPlace.name,
|
||
formatted_address: cachedPlace.formatted_address,
|
||
lat: cachedPlace.lat,
|
||
lng: cachedPlace.lng,
|
||
rating: cachedPlace.rating,
|
||
user_ratings_total: cachedPlace.user_ratings_total,
|
||
photo_reference: cachedPlace.photo_reference
|
||
})
|
||
} else {
|
||
// Cache MISS - fetching from Google and caching
|
||
console.log(`Cache MISS for "${item.place_name}" (normalized: "${normalizedName}") - fetching from Google and caching`)
|
||
|
||
let placeInfo = null;
|
||
|
||
if (GOOGLE_MAPS_API_KEY && GOOGLE_MAPS_API_KEY !== 'PASTE_YOUR_GOOGLE_MAPS_API_KEY_HERE') {
|
||
try {
|
||
// Text Search to get place_id and basic info
|
||
const searchUrl = `https://maps.googleapis.com/maps/api/place/textsearch/json?query=${encodeURIComponent(item.place_name + ' Cappadocia')}&key=${GOOGLE_MAPS_API_KEY}`
|
||
const searchRes = await fetch(searchUrl)
|
||
const searchData = await searchRes.json()
|
||
|
||
if (searchData.results && searchData.results.length > 0) {
|
||
const place = searchData.results[0]
|
||
placeInfo = {
|
||
place_id: place.place_id,
|
||
name: place.name,
|
||
formatted_address: place.formatted_address,
|
||
lat: place.geometry.location.lat,
|
||
lng: place.geometry.location.lng,
|
||
rating: place.rating || null,
|
||
user_ratings_total: place.user_ratings_total || null,
|
||
photo_reference: place.photos?.[0]?.photo_reference || null
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error(`Google Places API error for ${item.place_name}:`, err)
|
||
}
|
||
}
|
||
|
||
if (placeInfo) {
|
||
// Store in cache for future use with normalized name
|
||
const cacheEntry = {
|
||
place_name_normalized: normalizedName,
|
||
...placeInfo
|
||
}
|
||
|
||
await supabase
|
||
.from('places_cache')
|
||
.insert(cacheEntry)
|
||
|
||
verifiedItems.push({
|
||
...item,
|
||
...placeInfo
|
||
})
|
||
} else {
|
||
// No Google info found or key missing, use item as is but mark as unverified
|
||
verifiedItems.push({
|
||
...item,
|
||
unverified: true
|
||
})
|
||
}
|
||
}
|
||
}
|
||
verifiedDays.push({ ...day, items: verifiedItems })
|
||
}
|
||
|
||
// 3. Add time slots (Simple heuristic)
|
||
const finalDays = verifiedDays.map(day => {
|
||
let currentTime = new Date(`2026-01-01T09:00:00`)
|
||
const itemsWithTime = day.items.map(item => {
|
||
// Handle special cases: Balloon
|
||
if (item.place_name.toLowerCase().includes('balloon')) {
|
||
const start = "05:00"
|
||
const end = "08:00"
|
||
return { ...item, start_time: start, end_time: end }
|
||
}
|
||
|
||
const startTime = currentTime.toTimeString().slice(0, 5)
|
||
currentTime.setMinutes(currentTime.getMinutes() + (item.estimated_duration_minutes || 60))
|
||
const endTime = currentTime.toTimeString().slice(0, 5)
|
||
currentTime.setMinutes(currentTime.getMinutes() + 30) // 30 min travel/buffer
|
||
|
||
return { ...item, start_time: startTime, end_time: endTime }
|
||
})
|
||
return { ...day, items: itemsWithTime }
|
||
})
|
||
|
||
return new Response(JSON.stringify({ days: finalDays }), {
|
||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||
})
|
||
} catch (error) {
|
||
console.error('Final catch error:', error)
|
||
return new Response(JSON.stringify({ error: error.message }), {
|
||
status: 500,
|
||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||
})
|
||
}
|
||
}) |