Initial commit: Vuelato - buscador de vuelos
Some checks failed
ci / ci (22, ubuntu-latest) (push) Has been cancelled
Some checks failed
ci / ci (22, ubuntu-latest) (push) Has been cancelled
Nuxt 4 + Supabase + Flightics API. Incluye búsqueda de vuelos, inspiraciones, watchlist, tracking de precios y mapa interactivo. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
14
server/api/airlines.get.ts
Normal file
14
server/api/airlines.get.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { serverSupabaseServiceRole } from '#supabase/server'
|
||||
|
||||
export default defineCachedEventHandler(async (event) => {
|
||||
const supabase = serverSupabaseServiceRole(event)
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('airlines')
|
||||
.select('iata, icao, name, logo_url, website, booking_url, booking_url_template')
|
||||
.order('name')
|
||||
|
||||
if (error) throw createError({ statusCode: 500, message: error.message })
|
||||
|
||||
return { airlines: data }
|
||||
}, { maxAge: 60 * 60 * 24 }) // cache 24h
|
||||
4
server/api/check.post.ts
Normal file
4
server/api/check.post.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { bookingToken, local, passengersCount } = await readBody(event)
|
||||
return checkTrip(bookingToken, local, passengersCount)
|
||||
})
|
||||
3
server/api/countries.get.ts
Normal file
3
server/api/countries.get.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default defineCachedEventHandler(async () => {
|
||||
return getCountries()
|
||||
}, { maxAge: 60 * 60 * 24 }) // cache 24h
|
||||
64
server/api/destination-image.get.ts
Normal file
64
server/api/destination-image.get.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { serverSupabaseServiceRole } from '#supabase/server'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { city } = getQuery(event)
|
||||
if (!city || typeof city !== 'string') {
|
||||
throw createError({ statusCode: 400, message: 'city is required' })
|
||||
}
|
||||
|
||||
const cityKey = city.trim().toLowerCase()
|
||||
const client = serverSupabaseServiceRole(event)
|
||||
|
||||
// Check cache first
|
||||
const { data: cached } = await client
|
||||
.from('destination_images')
|
||||
.select('*')
|
||||
.eq('city_name', cityKey)
|
||||
.single()
|
||||
|
||||
if (cached) {
|
||||
// Refresh if older than 30 days
|
||||
const age = Date.now() - new Date(cached.cached_at).getTime()
|
||||
if (age < 30 * 24 * 60 * 60 * 1000) {
|
||||
return cached
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch from Unsplash
|
||||
const accessKey = process.env.UNSPLASH_ACCESS_KEY
|
||||
if (!accessKey) {
|
||||
throw createError({ statusCode: 500, message: 'UNSPLASH_ACCESS_KEY not configured' })
|
||||
}
|
||||
|
||||
const result = await $fetch<{ results: { urls: { regular: string; small: string }; user: { name: string; links: { html: string } } }[] }>('https://api.unsplash.com/search/photos', {
|
||||
query: {
|
||||
query: `${city} city travel`,
|
||||
per_page: 1,
|
||||
orientation: 'landscape',
|
||||
},
|
||||
headers: {
|
||||
Authorization: `Client-ID ${accessKey}`,
|
||||
},
|
||||
}).catch(() => null)
|
||||
|
||||
if (!result?.results?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const photo = result.results[0]
|
||||
const row = {
|
||||
city_name: cityKey,
|
||||
image_url: photo.urls.regular,
|
||||
thumb_url: photo.urls.small,
|
||||
photographer: photo.user.name,
|
||||
photographer_url: photo.user.links.html,
|
||||
cached_at: new Date().toISOString(),
|
||||
}
|
||||
|
||||
// Upsert cache
|
||||
await client
|
||||
.from('destination_images')
|
||||
.upsert(row, { onConflict: 'city_name' })
|
||||
|
||||
return row
|
||||
})
|
||||
4
server/api/detail.post.ts
Normal file
4
server/api/detail.post.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { bookingToken, local, passengersCount } = await readBody(event)
|
||||
return getTripDetail(bookingToken, local, passengersCount)
|
||||
})
|
||||
100
server/api/flight-info.get.ts
Normal file
100
server/api/flight-info.get.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
export default defineCachedEventHandler(async (event) => {
|
||||
const { flightno } = getQuery(event)
|
||||
if (!flightno || typeof flightno !== 'string') {
|
||||
throw createError({ statusCode: 400, message: 'flightno is required' })
|
||||
}
|
||||
|
||||
const code = flightno.replace(/\s/g, '').toUpperCase()
|
||||
const fr24Url = `https://www.flightradar24.com/data/flights/${code.toLowerCase()}`
|
||||
|
||||
// FR24 live feed
|
||||
const data = await $fetch<any>(`https://data-live.flightradar24.com/zones/fcgi/feed.js`, {
|
||||
query: { flightno: code, faa: 1, satellite: 1, mlat: 1, flarm: 1, adsb: 1, gnd: 1, air: 1, vehicles: 0, estimated: 1, gliders: 0 },
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
}).catch(() => null)
|
||||
|
||||
if (!data) return { found: false, fr24Url }
|
||||
|
||||
// Parse flight entries (skip metadata keys)
|
||||
const flights = Object.entries(data)
|
||||
.filter(([key]) => !['full_count', 'version', 'stats'].includes(key))
|
||||
.map(([id, val]: [string, any]) => {
|
||||
if (!Array.isArray(val) || val.length < 18) return null
|
||||
return {
|
||||
fr24Id: id,
|
||||
icao24: val[0],
|
||||
lat: val[1],
|
||||
lon: val[2],
|
||||
heading: val[3],
|
||||
altitude: val[4],
|
||||
speed: val[5],
|
||||
squawk: val[6],
|
||||
aircraft: val[8],
|
||||
registration: val[9],
|
||||
timestamp: val[10],
|
||||
origin: val[11],
|
||||
destination: val[12],
|
||||
flightNumber: val[13],
|
||||
onGround: val[14] === 1,
|
||||
verticalSpeed: val[15],
|
||||
callsign: val[16],
|
||||
airline: val[18]
|
||||
}
|
||||
})
|
||||
.filter((f): f is NonNullable<typeof f> => f != null && (f.lat !== 0 || f.lon !== 0 || f.aircraft != null))
|
||||
|
||||
if (flights.length === 0) return { found: false, fr24Url }
|
||||
|
||||
const flight = flights[0]
|
||||
|
||||
// Get detail for richer data
|
||||
let detail: any = null
|
||||
if (flight.fr24Id) {
|
||||
detail = await $fetch<any>(`https://data-live.flightradar24.com/clickhandler/`, {
|
||||
query: { version: '1.5', flight: flight.fr24Id },
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
|
||||
}
|
||||
}).catch(() => null)
|
||||
}
|
||||
|
||||
// Determine status
|
||||
let status = 'Programado'
|
||||
if (detail?.status?.text) {
|
||||
status = detail.status.text
|
||||
} else if (flight.altitude > 0 && !flight.onGround) {
|
||||
status = 'En vuelo'
|
||||
} else if (flight.onGround && flight.speed > 5) {
|
||||
status = 'En tierra (taxiing)'
|
||||
} else if (flight.onGround) {
|
||||
status = 'En tierra'
|
||||
}
|
||||
|
||||
return {
|
||||
found: true,
|
||||
fr24Url,
|
||||
flight: {
|
||||
flightNumber: flight.flightNumber || code,
|
||||
callsign: flight.callsign,
|
||||
aircraft: detail?.aircraft?.model?.text || flight.aircraft || null,
|
||||
aircraftCode: flight.aircraft,
|
||||
registration: flight.registration,
|
||||
airline: detail?.airline?.name || flight.airline || null,
|
||||
origin: flight.origin,
|
||||
destination: flight.destination,
|
||||
lat: flight.lat,
|
||||
lon: flight.lon,
|
||||
altitude: flight.altitude,
|
||||
speed: flight.speed,
|
||||
heading: flight.heading,
|
||||
onGround: flight.onGround,
|
||||
verticalSpeed: flight.verticalSpeed,
|
||||
status,
|
||||
departureDelay: detail?.time?.historical?.delay?.departure ?? null,
|
||||
arrivalDelay: detail?.time?.historical?.delay?.arrival ?? null
|
||||
}
|
||||
}
|
||||
}, { maxAge: 60 * 5 }) // Cache 5 min
|
||||
4
server/api/inspirations.get.ts
Normal file
4
server/api/inspirations.get.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { from, take, locale } = getQuery(event)
|
||||
return getInspirations(from as string, Number(take) || 36, (locale as string) || 'en')
|
||||
})
|
||||
3
server/api/locations.get.ts
Normal file
3
server/api/locations.get.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default defineCachedEventHandler(async () => {
|
||||
return getLocations()
|
||||
}, { maxAge: 60 * 60 * 24 }) // cache 24h
|
||||
4
server/api/multi-city-inspirations.post.ts
Normal file
4
server/api/multi-city-inspirations.post.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { startLocationsCodes, locale, take } = await readBody(event)
|
||||
return getMultiCityInspirations(startLocationsCodes, locale, take)
|
||||
})
|
||||
16
server/api/profile.get.ts
Normal file
16
server/api/profile.get.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { serverSupabaseClient } from '#supabase/server'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const client = await serverSupabaseClient(event)
|
||||
const { data: { user } } = await client.auth.getUser()
|
||||
if (!user) throw createError({ statusCode: 401, message: 'No autenticado' })
|
||||
|
||||
const { data, error } = await client
|
||||
.from('profiles')
|
||||
.select('*')
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
|
||||
if (error) throw createError({ statusCode: 500, message: error.message })
|
||||
return data
|
||||
})
|
||||
28
server/api/profile.patch.ts
Normal file
28
server/api/profile.patch.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { serverSupabaseClient } from '#supabase/server'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const client = await serverSupabaseClient(event)
|
||||
const { data: { user } } = await client.auth.getUser()
|
||||
if (!user) throw createError({ statusCode: 401, message: 'No autenticado' })
|
||||
|
||||
const body = await readBody(event)
|
||||
const allowed = ['home_airports', 'default_adults', 'default_children', 'default_infants', 'locale', 'show_origin_time']
|
||||
const patch: Record<string, unknown> = {}
|
||||
for (const key of allowed) {
|
||||
if (body[key] !== undefined) patch[key] = body[key]
|
||||
}
|
||||
|
||||
if (Object.keys(patch).length === 0) {
|
||||
throw createError({ statusCode: 400, message: 'Nada que actualizar' })
|
||||
}
|
||||
|
||||
const { data, error } = await client
|
||||
.from('profiles')
|
||||
.update(patch)
|
||||
.eq('id', user.id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw createError({ statusCode: 500, message: error.message })
|
||||
return data
|
||||
})
|
||||
4
server/api/route-flights.post.ts
Normal file
4
server/api/route-flights.post.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { from, to, locale, passengersCount } = await readBody(event)
|
||||
return getRouteFlights(from, to, locale, passengersCount)
|
||||
})
|
||||
52
server/api/search.post.ts
Normal file
52
server/api/search.post.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { serverSupabaseServiceRole } from '#supabase/server'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event)
|
||||
const poll = body._poll !== false
|
||||
delete body._poll
|
||||
|
||||
const supabase = serverSupabaseServiceRole(event)
|
||||
const paramsHash = computeSearchHash(body)
|
||||
|
||||
// Buscar en cache (TTL 1 hora)
|
||||
const { data: cached } = await supabase
|
||||
.from('search_cache')
|
||||
.select('trips, cheapest_price, total_results, fetched_at')
|
||||
.eq('params_hash', paramsHash)
|
||||
.gte('fetched_at', new Date(Date.now() - 60 * 60 * 1000).toISOString())
|
||||
.single()
|
||||
|
||||
if (cached) {
|
||||
const trips = (cached.trips as any[]) || []
|
||||
return {
|
||||
trips,
|
||||
notComplete: false,
|
||||
contractVersion: 0,
|
||||
responseId: `cache-${paramsHash.slice(0, 8)}`
|
||||
}
|
||||
}
|
||||
|
||||
// Sin cache — llamar a Flightics
|
||||
const result = poll
|
||||
? await searchTripsComplete(body, 3)
|
||||
: await searchTrips(body)
|
||||
|
||||
// Guardar en cache (upsert por params_hash)
|
||||
if (result.trips && result.trips.length > 0) {
|
||||
const prices = result.trips.map(t => t.totalCost).filter(p => p > 0)
|
||||
const cheapest = prices.length > 0 ? Math.min(...prices) : null
|
||||
|
||||
await supabase
|
||||
.from('search_cache')
|
||||
.upsert({
|
||||
params_hash: paramsHash,
|
||||
search_params: body,
|
||||
trips: result.trips,
|
||||
cheapest_price: cheapest,
|
||||
total_results: result.trips.length,
|
||||
fetched_at: new Date().toISOString()
|
||||
}, { onConflict: 'params_hash' })
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
25
server/api/sync/airlines.post.ts
Normal file
25
server/api/sync/airlines.post.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { serverSupabaseServiceRole } from '#supabase/server'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const supabase = serverSupabaseServiceRole(event)
|
||||
|
||||
const airlines = await getAirlinesFromWikidata()
|
||||
|
||||
const rows = airlines.map(a => ({
|
||||
iata: a.iata,
|
||||
icao: a.icao,
|
||||
name: a.name,
|
||||
logo_url: a.logoUrl,
|
||||
website: a.website,
|
||||
updated_at: new Date().toISOString()
|
||||
}))
|
||||
|
||||
if (rows.length > 0) {
|
||||
const { error } = await supabase
|
||||
.from('airlines')
|
||||
.upsert(rows as never, { onConflict: 'iata' })
|
||||
if (error) throw createError({ statusCode: 500, message: error.message })
|
||||
}
|
||||
|
||||
return { count: rows.length }
|
||||
})
|
||||
59
server/api/sync/locations.post.ts
Normal file
59
server/api/sync/locations.post.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { serverSupabaseServiceRole } from '#supabase/server'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const supabase = serverSupabaseServiceRole(event)
|
||||
|
||||
// Fetch from Flightics
|
||||
const [locData, countryData] = await Promise.all([
|
||||
getLocations(),
|
||||
getCountries()
|
||||
])
|
||||
|
||||
// Build lookup maps for city names and countries
|
||||
const cityMap = new Map(locData.cities.map(c => [c.id, c]))
|
||||
const locCountryMap = new Map(locData.countries.map(c => [c.id, c]))
|
||||
|
||||
// Upsert airports enriched with city and country names
|
||||
const airports = locData.airports.map(a => {
|
||||
const city = cityMap.get(a.cityId)
|
||||
const country = city ? locCountryMap.get(city.countryId) : undefined
|
||||
return {
|
||||
iata: a.iata,
|
||||
icao: a.icao,
|
||||
name: a.nameEng,
|
||||
lat: a.lat,
|
||||
lon: a.lon,
|
||||
city_id: a.cityId,
|
||||
city_name: city?.nameEng || '',
|
||||
country_code: country?.isoCode2 || '',
|
||||
country_name: country?.nameEng || '',
|
||||
updated_at: new Date().toISOString()
|
||||
}
|
||||
})
|
||||
|
||||
if (airports.length > 0) {
|
||||
const { error: airportErr } = await supabase
|
||||
.from('airports')
|
||||
.upsert(airports as never, { onConflict: 'iata' })
|
||||
if (airportErr) throw createError({ statusCode: 500, message: airportErr.message })
|
||||
}
|
||||
|
||||
// Upsert countries from dedicated endpoint
|
||||
const countries = countryData.countries.map(c => ({
|
||||
iso_code2: c.isoCode2,
|
||||
iso_code3: c.isoCode3,
|
||||
name_eng: c.nameEng,
|
||||
name_native: c.nameNative,
|
||||
phone_prefix: c.phonePreselection,
|
||||
updated_at: new Date().toISOString()
|
||||
}))
|
||||
|
||||
if (countries.length > 0) {
|
||||
const { error: countryErr } = await supabase
|
||||
.from('countries')
|
||||
.upsert(countries as never, { onConflict: 'iso_code2' })
|
||||
if (countryErr) throw createError({ statusCode: 500, message: countryErr.message })
|
||||
}
|
||||
|
||||
return { count: airports.length, countries: countries.length }
|
||||
})
|
||||
22
server/api/tracking/[id].delete.ts
Normal file
22
server/api/tracking/[id].delete.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { serverSupabaseServiceRole, serverSupabaseClient } from '#supabase/server'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const client = await serverSupabaseClient(event)
|
||||
const { data: { user } } = await client.auth.getUser()
|
||||
if (!user) throw createError({ statusCode: 401, message: 'No autenticado' })
|
||||
|
||||
const id = getRouterParam(event, 'id')
|
||||
if (!id) throw createError({ statusCode: 400, message: 'ID requerido' })
|
||||
|
||||
const supabase = serverSupabaseServiceRole(event)
|
||||
|
||||
const { error } = await supabase
|
||||
.from('tracked_searches')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
|
||||
if (error) throw createError({ statusCode: 500, message: error.message })
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
46
server/api/tracking/[id].patch.ts
Normal file
46
server/api/tracking/[id].patch.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { serverSupabaseServiceRole, serverSupabaseClient } from '#supabase/server'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const client = await serverSupabaseClient(event)
|
||||
const { data: { user } } = await client.auth.getUser()
|
||||
if (!user) throw createError({ statusCode: 401, message: 'No autenticado' })
|
||||
|
||||
const id = getRouterParam(event, 'id')
|
||||
if (!id) throw createError({ statusCode: 400, message: 'ID requerido' })
|
||||
|
||||
const body = await readBody(event)
|
||||
const allowed = ['name', 'interval_hours', 'is_active', 'expires_at', 'search_params', 'route_summary']
|
||||
const patch: Record<string, unknown> = {}
|
||||
|
||||
for (const key of allowed) {
|
||||
if (body[key] !== undefined) patch[key] = body[key]
|
||||
}
|
||||
|
||||
if (patch.interval_hours) {
|
||||
patch.interval_hours = Math.min(Math.max(patch.interval_hours as number, 6), 168)
|
||||
}
|
||||
|
||||
if (Object.keys(patch).length === 0) {
|
||||
throw createError({ statusCode: 400, message: 'Nada que actualizar' })
|
||||
}
|
||||
|
||||
// Si cambian los parametros de busqueda, forzar ejecucion inmediata
|
||||
if (patch.search_params) {
|
||||
patch.next_run_at = new Date().toISOString()
|
||||
}
|
||||
|
||||
const supabase = serverSupabaseServiceRole(event)
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('tracked_searches')
|
||||
.update(patch as never)
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw createError({ statusCode: 500, message: error.message })
|
||||
if (!data) throw createError({ statusCode: 404, message: 'Busqueda no encontrada' })
|
||||
|
||||
return data
|
||||
})
|
||||
37
server/api/tracking/[id]/history.get.ts
Normal file
37
server/api/tracking/[id]/history.get.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { serverSupabaseServiceRole, serverSupabaseClient } from '#supabase/server'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const client = await serverSupabaseClient(event)
|
||||
const { data: { user } } = await client.auth.getUser()
|
||||
if (!user) throw createError({ statusCode: 401, message: 'No autenticado' })
|
||||
|
||||
const id = getRouterParam(event, 'id')
|
||||
if (!id) throw createError({ statusCode: 400, message: 'ID requerido' })
|
||||
|
||||
const query = getQuery(event)
|
||||
const days = Number(query.days) || 30
|
||||
|
||||
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString()
|
||||
|
||||
const supabase = serverSupabaseServiceRole(event)
|
||||
|
||||
const { data: search } = await supabase
|
||||
.from('tracked_searches')
|
||||
.select('id')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (!search) throw createError({ statusCode: 404, message: 'No encontrado' })
|
||||
|
||||
const { data: snapshots, error } = await supabase
|
||||
.from('price_snapshots')
|
||||
.select('cheapest_price, avg_price, median_price, total_results, recorded_at')
|
||||
.eq('tracked_search_id', id)
|
||||
.gte('recorded_at', since)
|
||||
.order('recorded_at', { ascending: true })
|
||||
|
||||
if (error) throw createError({ statusCode: 500, message: error.message })
|
||||
|
||||
return snapshots || []
|
||||
})
|
||||
36
server/api/tracking/[id]/runs.get.ts
Normal file
36
server/api/tracking/[id]/runs.get.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { serverSupabaseServiceRole, serverSupabaseClient } from '#supabase/server'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const client = await serverSupabaseClient(event)
|
||||
const { data: { user } } = await client.auth.getUser()
|
||||
if (!user) throw createError({ statusCode: 401, message: 'No autenticado' })
|
||||
|
||||
const id = getRouterParam(event, 'id')
|
||||
if (!id) throw createError({ statusCode: 400, message: 'ID requerido' })
|
||||
|
||||
const query = getQuery(event)
|
||||
const limit = Math.min(Number(query.limit) || 20, 100)
|
||||
const offset = Number(query.offset) || 0
|
||||
|
||||
const supabase = serverSupabaseServiceRole(event)
|
||||
|
||||
const { data: search } = await supabase
|
||||
.from('tracked_searches')
|
||||
.select('id')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (!search) throw createError({ statusCode: 404, message: 'No encontrado' })
|
||||
|
||||
const { data: runs, error } = await supabase
|
||||
.from('search_runs')
|
||||
.select('*')
|
||||
.eq('tracked_search_id', id)
|
||||
.order('created_at', { ascending: false })
|
||||
.range(offset, offset + limit - 1)
|
||||
|
||||
if (error) throw createError({ statusCode: 500, message: error.message })
|
||||
|
||||
return runs || []
|
||||
})
|
||||
33
server/api/tracking/index.get.ts
Normal file
33
server/api/tracking/index.get.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { serverSupabaseServiceRole, serverSupabaseClient } from '#supabase/server'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const client = await serverSupabaseClient(event)
|
||||
const { data: { user } } = await client.auth.getUser()
|
||||
if (!user) throw createError({ statusCode: 401, message: 'No autenticado' })
|
||||
|
||||
const supabase = serverSupabaseServiceRole(event)
|
||||
|
||||
const { data: searches, error } = await supabase
|
||||
.from('tracked_searches')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) throw createError({ statusCode: 500, message: error.message })
|
||||
|
||||
const searchesWithSnapshot = await Promise.all(
|
||||
((searches as Record<string, unknown>[]) || []).map(async (s) => {
|
||||
const { data: snapshot } = await supabase
|
||||
.from('price_snapshots')
|
||||
.select('cheapest_price, avg_price, median_price, total_results, recorded_at')
|
||||
.eq('tracked_search_id', s.id as string)
|
||||
.order('recorded_at', { ascending: false })
|
||||
.limit(1)
|
||||
.single()
|
||||
|
||||
return { ...s, latest_snapshot: snapshot || null }
|
||||
})
|
||||
)
|
||||
|
||||
return searchesWithSnapshot
|
||||
})
|
||||
36
server/api/tracking/index.post.ts
Normal file
36
server/api/tracking/index.post.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { serverSupabaseServiceRole, serverSupabaseClient } from '#supabase/server'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const client = await serverSupabaseClient(event)
|
||||
const { data: { user } } = await client.auth.getUser()
|
||||
if (!user) throw createError({ statusCode: 401, message: 'No autenticado' })
|
||||
|
||||
const body = await readBody(event)
|
||||
const { name, searchParams, routeSummary, intervalHours, expiresAt } = body
|
||||
|
||||
if (!name || !searchParams || !routeSummary) {
|
||||
throw createError({ statusCode: 400, message: 'Faltan campos obligatorios: name, searchParams, routeSummary' })
|
||||
}
|
||||
|
||||
const interval = Math.min(Math.max(intervalHours || 24, 6), 168)
|
||||
|
||||
const supabase = serverSupabaseServiceRole(event)
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('tracked_searches')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
name,
|
||||
search_params: searchParams,
|
||||
route_summary: routeSummary,
|
||||
interval_hours: interval,
|
||||
next_run_at: new Date().toISOString(),
|
||||
expires_at: expiresAt || null
|
||||
} as never)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw createError({ statusCode: 500, message: error.message })
|
||||
|
||||
return data
|
||||
})
|
||||
4
server/api/weekend-search.post.ts
Normal file
4
server/api/weekend-search.post.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event)
|
||||
return searchWeekendTrips(body)
|
||||
})
|
||||
258
server/plugins/search-worker.ts
Normal file
258
server/plugins/search-worker.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { SearchParams, Trip } from '../utils/flightics'
|
||||
|
||||
const POLL_INTERVAL = 60_000 // Revisar cola cada 60 segundos
|
||||
const DELAY_BETWEEN_JOBS = 10_000 // 10s entre busquedas (rate limit)
|
||||
const MAX_JOBS_PER_CYCLE = 5 // Maximo jobs por ciclo
|
||||
const SEARCH_TIMEOUT = 30_000 // Timeout por busqueda individual
|
||||
|
||||
interface TrackedJob {
|
||||
id: string
|
||||
search_params: SearchParams
|
||||
interval_hours: number
|
||||
run_count: number
|
||||
expires_at: string | null
|
||||
}
|
||||
|
||||
export default defineNitroPlugin((nitro) => {
|
||||
let running = false
|
||||
|
||||
const interval = setInterval(processQueue, POLL_INTERVAL)
|
||||
nitro.hooks.hook('close', () => clearInterval(interval))
|
||||
|
||||
// Primera ejecucion tras 10s de arranque
|
||||
setTimeout(processQueue, 10_000)
|
||||
|
||||
async function processQueue() {
|
||||
if (running) return
|
||||
running = true
|
||||
|
||||
try {
|
||||
const supabase = getSupabaseAdmin()
|
||||
|
||||
// Buscar tracked_searches que necesitan ejecutarse
|
||||
const { data: pendingJobs, error } = await supabase
|
||||
.from('tracked_searches')
|
||||
.select('*')
|
||||
.eq('is_active', true)
|
||||
.lte('next_run_at', new Date().toISOString())
|
||||
.order('next_run_at', { ascending: true })
|
||||
.limit(MAX_JOBS_PER_CYCLE)
|
||||
|
||||
if (error || !pendingJobs || pendingJobs.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`[search-worker] Procesando ${pendingJobs.length} busquedas pendientes`)
|
||||
|
||||
for (const job of pendingJobs as unknown as TrackedJob[]) {
|
||||
try {
|
||||
await processJob(supabase, job)
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
console.error(`[search-worker] Error en job ${job.id}:`, msg)
|
||||
await markJobFailed(supabase, job, msg)
|
||||
}
|
||||
|
||||
// Esperar entre jobs para no saturar Flightics
|
||||
if ((pendingJobs as unknown as TrackedJob[]).indexOf(job) < pendingJobs.length - 1) {
|
||||
await new Promise(r => setTimeout(r, DELAY_BETWEEN_JOBS))
|
||||
}
|
||||
}
|
||||
|
||||
// Limpiar cache viejo (>2 horas)
|
||||
await supabase
|
||||
.from('search_cache')
|
||||
.delete()
|
||||
.lt('fetched_at', new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString())
|
||||
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
console.error('[search-worker] Error general:', msg)
|
||||
} finally {
|
||||
running = false
|
||||
}
|
||||
}
|
||||
|
||||
async function processJob(supabase: SupabaseClient, job: TrackedJob) {
|
||||
const searchParams = job.search_params
|
||||
|
||||
// Validar que las fechas no esten en el pasado
|
||||
const departureDateEnd = searchParams.departureDateInterval?.end
|
||||
if (departureDateEnd) {
|
||||
const endDate = new Date(departureDateEnd)
|
||||
if (endDate < new Date()) {
|
||||
console.log(`[search-worker] Job ${job.id}: fechas en el pasado, desactivando`)
|
||||
await supabase
|
||||
.from('tracked_searches')
|
||||
.update({ is_active: false, last_error: 'Fechas de busqueda en el pasado' } as Record<string, unknown>)
|
||||
.eq('id', job.id)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Verificar expiracion
|
||||
if (job.expires_at && new Date(job.expires_at) < new Date()) {
|
||||
console.log(`[search-worker] Job ${job.id}: expirado, desactivando`)
|
||||
await supabase
|
||||
.from('tracked_searches')
|
||||
.update({ is_active: false } as Record<string, unknown>)
|
||||
.eq('id', job.id)
|
||||
return
|
||||
}
|
||||
|
||||
// Crear search_run
|
||||
const { data: run } = await supabase
|
||||
.from('search_runs')
|
||||
.insert({
|
||||
tracked_search_id: job.id,
|
||||
status: 'running',
|
||||
started_at: new Date().toISOString()
|
||||
} as Record<string, unknown>)
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (!run) throw new Error('No se pudo crear search_run')
|
||||
|
||||
// Buscar en cache primero
|
||||
const paramsHash = computeSearchHash(searchParams as unknown as Record<string, unknown>)
|
||||
let trips: Trip[] = []
|
||||
let fromCache = false
|
||||
|
||||
const { data: cached } = await supabase
|
||||
.from('search_cache')
|
||||
.select('trips')
|
||||
.eq('params_hash', paramsHash)
|
||||
.gte('fetched_at', new Date(Date.now() - 60 * 60 * 1000).toISOString())
|
||||
.single()
|
||||
|
||||
if (cached?.trips) {
|
||||
trips = cached.trips as unknown as Trip[]
|
||||
fromCache = true
|
||||
console.log(`[search-worker] Job ${job.id}: usando cache (${trips.length} trips)`)
|
||||
} else {
|
||||
// Llamar a Flightics con timeout
|
||||
console.log(`[search-worker] Job ${job.id}: buscando en Flightics...`)
|
||||
const result = await Promise.race([
|
||||
searchTripsComplete(searchParams, 3),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Timeout de busqueda')), SEARCH_TIMEOUT)
|
||||
)
|
||||
])
|
||||
trips = result.trips || []
|
||||
|
||||
// Guardar en cache
|
||||
if (trips.length > 0) {
|
||||
const prices = trips.map(t => t.totalCost).filter(p => p > 0)
|
||||
await supabase
|
||||
.from('search_cache')
|
||||
.upsert({
|
||||
params_hash: paramsHash,
|
||||
search_params: searchParams,
|
||||
trips,
|
||||
cheapest_price: prices.length > 0 ? Math.min(...prices) : null,
|
||||
total_results: trips.length,
|
||||
fetched_at: new Date().toISOString()
|
||||
} as Record<string, unknown>, { onConflict: 'params_hash' })
|
||||
}
|
||||
}
|
||||
|
||||
// Calcular estadisticas de precio
|
||||
const prices = trips.map(t => t.totalCost).filter(p => p > 0).sort((a, b) => a - b)
|
||||
const cheapest = prices.length > 0 ? prices[0] : null
|
||||
const avg = prices.length > 0 ? Math.round(prices.reduce((s, p) => s + p, 0) / prices.length * 100) / 100 : null
|
||||
const median = prices.length > 0 ? prices[Math.floor(prices.length / 2)] : null
|
||||
|
||||
// Top 5 trips mas baratos (resumen compacto)
|
||||
const topTrips = trips
|
||||
.filter(t => t.totalCost > 0)
|
||||
.sort((a, b) => a.totalCost - b.totalCost)
|
||||
.slice(0, 5)
|
||||
.map(t => ({
|
||||
price: t.totalCost,
|
||||
currency: t.currency,
|
||||
bookingToken: t.bookingToken,
|
||||
legs: t.legs.map(l => ({
|
||||
from: l.segments[0]?.departureCode,
|
||||
to: l.segments[l.segments.length - 1]?.arrivalCode,
|
||||
departure: l.segments[0]?.departureDate,
|
||||
airlines: [...new Set(l.segments.map(s => s.company?.code).filter(Boolean))]
|
||||
}))
|
||||
}))
|
||||
|
||||
// Actualizar search_run
|
||||
const runId = (run as Record<string, unknown>).id as string
|
||||
await supabase
|
||||
.from('search_runs')
|
||||
.update({
|
||||
status: 'completed',
|
||||
cheapest_price: cheapest,
|
||||
total_trips_found: trips.length,
|
||||
top_trips: topTrips,
|
||||
from_cache: fromCache,
|
||||
completed_at: new Date().toISOString()
|
||||
} as Record<string, unknown>)
|
||||
.eq('id', runId)
|
||||
|
||||
// Insertar price_snapshot
|
||||
if (cheapest !== null) {
|
||||
await supabase
|
||||
.from('price_snapshots')
|
||||
.insert({
|
||||
tracked_search_id: job.id,
|
||||
search_run_id: runId,
|
||||
cheapest_price: cheapest,
|
||||
avg_price: avg,
|
||||
median_price: median,
|
||||
total_results: trips.length,
|
||||
recorded_at: new Date().toISOString()
|
||||
} as Record<string, unknown>)
|
||||
}
|
||||
|
||||
// Actualizar tracked_search: next_run_at, last_run_at, run_count
|
||||
const nextRun = new Date(Date.now() + job.interval_hours * 60 * 60 * 1000)
|
||||
await supabase
|
||||
.from('tracked_searches')
|
||||
.update({
|
||||
next_run_at: nextRun.toISOString(),
|
||||
last_run_at: new Date().toISOString(),
|
||||
run_count: (job.run_count || 0) + 1,
|
||||
last_error: null
|
||||
} as Record<string, unknown>)
|
||||
.eq('id', job.id)
|
||||
|
||||
console.log(`[search-worker] Job ${job.id} completado: ${trips.length} trips, mejor precio: ${cheapest}`)
|
||||
}
|
||||
|
||||
async function markJobFailed(supabase: SupabaseClient, job: TrackedJob, errorMessage: string) {
|
||||
// Buscar el run mas reciente en estado running
|
||||
const { data: runs } = await supabase
|
||||
.from('search_runs')
|
||||
.select('id')
|
||||
.eq('tracked_search_id', job.id)
|
||||
.eq('status', 'running')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
|
||||
if (runs && runs.length > 0) {
|
||||
await supabase
|
||||
.from('search_runs')
|
||||
.update({
|
||||
status: 'failed',
|
||||
error_message: errorMessage,
|
||||
completed_at: new Date().toISOString()
|
||||
} as Record<string, unknown>)
|
||||
.eq('id', (runs[0] as Record<string, unknown>).id as string)
|
||||
}
|
||||
|
||||
// Programar reintento (avanzar next_run_at)
|
||||
const nextRun = new Date(Date.now() + job.interval_hours * 60 * 60 * 1000)
|
||||
await supabase
|
||||
.from('tracked_searches')
|
||||
.update({
|
||||
last_error: errorMessage,
|
||||
next_run_at: nextRun.toISOString()
|
||||
} as Record<string, unknown>)
|
||||
.eq('id', job.id)
|
||||
}
|
||||
})
|
||||
300
server/utils/flightics.ts
Normal file
300
server/utils/flightics.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
const BASE_URL = 'https://www.flightics.com/api/v1'
|
||||
const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36'
|
||||
|
||||
const HEADERS = {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'User-Agent': USER_AGENT,
|
||||
'sec-ch-ua': '"Chromium";v="147", "Not.A/Brand";v="8"',
|
||||
'sec-ch-ua-mobile': '?0',
|
||||
'sec-ch-ua-platform': '"macOS"',
|
||||
'Referer': 'https://www.flightics.com/'
|
||||
}
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface PassengersCount {
|
||||
adult: number
|
||||
child: number
|
||||
infant: number
|
||||
}
|
||||
|
||||
export interface StopConfig {
|
||||
locations: string[]
|
||||
stayRange: { min: number, max: number }
|
||||
stayDateRange: { begin: string, end: string }
|
||||
continueFromAny: boolean
|
||||
}
|
||||
|
||||
export interface SearchParams {
|
||||
departures: string[]
|
||||
local: string
|
||||
departureDateInterval: { begin: string, end: string }
|
||||
stops: StopConfig[]
|
||||
endInSameLocation: boolean
|
||||
maxStops: number | null
|
||||
fixStopsOrder: boolean
|
||||
stopLength: { min: number, max: number, isSet: boolean }
|
||||
maxResults: number
|
||||
passengersCount: PassengersCount
|
||||
}
|
||||
|
||||
export interface Company {
|
||||
name: string | null
|
||||
code: string
|
||||
icao: string
|
||||
}
|
||||
|
||||
export interface Segment {
|
||||
id: string
|
||||
departureCode: string
|
||||
departureCity: string
|
||||
departureDate: string
|
||||
departureTimestamp: number
|
||||
departureDateUtc: string
|
||||
departureUtcTimestamp: number
|
||||
arrivalCode: string
|
||||
arrivalCity: string
|
||||
arrivalDate: string
|
||||
arrivalTimestamp: number
|
||||
arrivalDateUtc: string
|
||||
arrivalUtcTimestamp: number
|
||||
number: number
|
||||
company: Company
|
||||
isHidden: boolean
|
||||
}
|
||||
|
||||
export interface Leg {
|
||||
segments: Segment[]
|
||||
}
|
||||
|
||||
export interface Trip {
|
||||
legs: Leg[]
|
||||
totalCost: number
|
||||
bookingToken: string
|
||||
currency: string
|
||||
deepLink: string | null
|
||||
}
|
||||
|
||||
export interface SearchResponse {
|
||||
trips: Trip[]
|
||||
notComplete: boolean
|
||||
contractVersion: number
|
||||
responseId: string
|
||||
}
|
||||
|
||||
export interface DetailResponse {
|
||||
trip: Trip
|
||||
}
|
||||
|
||||
export interface Country {
|
||||
isoCode2: string
|
||||
isoCode3: string
|
||||
nameEng: string
|
||||
nameNative: string
|
||||
phonePreselection: string
|
||||
}
|
||||
|
||||
export interface InspirationItem {
|
||||
from: string
|
||||
to: string[]
|
||||
minPrice: number
|
||||
minStops: number
|
||||
}
|
||||
|
||||
export interface InspirationsResponse {
|
||||
items: InspirationItem[]
|
||||
}
|
||||
|
||||
// --- API: Search ---
|
||||
|
||||
export async function searchTrips(params: SearchParams): Promise<SearchResponse> {
|
||||
return $fetch(`${BASE_URL}/trips/search`, {
|
||||
method: 'POST',
|
||||
headers: HEADERS,
|
||||
body: params
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll search until notComplete is false or maxPolls reached.
|
||||
* The API returns partial results with notComplete: true.
|
||||
* Re-calling with the same params fetches more results.
|
||||
*/
|
||||
export async function searchTripsComplete(params: SearchParams, maxPolls: number = 3): Promise<SearchResponse> {
|
||||
let allTrips: Trip[] = []
|
||||
let lastResponse: SearchResponse | null = null
|
||||
|
||||
for (let i = 0; i < maxPolls; i++) {
|
||||
const res = await searchTrips(params)
|
||||
allTrips = [...allTrips, ...res.trips]
|
||||
lastResponse = res
|
||||
if (!res.notComplete) break
|
||||
// Small delay between polls
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
}
|
||||
|
||||
return {
|
||||
trips: allTrips,
|
||||
notComplete: lastResponse?.notComplete ?? false,
|
||||
contractVersion: lastResponse?.contractVersion ?? 0,
|
||||
responseId: lastResponse?.responseId ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
// --- API: Trip Detail & Check ---
|
||||
|
||||
export async function getTripDetail(bookingToken: string, local: string, passengersCount: PassengersCount): Promise<DetailResponse> {
|
||||
return $fetch(`${BASE_URL}/trip/detail`, {
|
||||
method: 'POST',
|
||||
headers: HEADERS,
|
||||
body: { bookingToken, local, passengersCount }
|
||||
})
|
||||
}
|
||||
|
||||
export async function checkTrip(bookingToken: string, local: string, passengersCount: PassengersCount): Promise<DetailResponse> {
|
||||
return $fetch(`${BASE_URL}/trip/check`, {
|
||||
method: 'POST',
|
||||
headers: HEADERS,
|
||||
body: { bookingToken, local, passengersCount }
|
||||
})
|
||||
}
|
||||
|
||||
// --- API: Inspirations ---
|
||||
|
||||
export async function getInspirations(from: string, take: number = 100, locale: string = 'en'): Promise<InspirationsResponse> {
|
||||
return $fetch(`${BASE_URL}/inspirations/search`, {
|
||||
method: 'GET',
|
||||
headers: { 'User-Agent': USER_AGENT },
|
||||
query: { from, take, locale }
|
||||
})
|
||||
}
|
||||
|
||||
// --- API: Weekend Search ---
|
||||
|
||||
export async function searchWeekendTrips(params: SearchParams): Promise<SearchResponse> {
|
||||
return $fetch(`${BASE_URL}/trips/weekend/search`, {
|
||||
method: 'POST',
|
||||
headers: HEADERS,
|
||||
body: params
|
||||
})
|
||||
}
|
||||
|
||||
// --- API: Route Flights ---
|
||||
|
||||
export interface RouteFlightsResponse {
|
||||
contractVersion: number
|
||||
returnResults: {
|
||||
trips: Trip[]
|
||||
}[]
|
||||
}
|
||||
|
||||
export async function getRouteFlights(from: string, to: string, locale: string = 'en', passengersCount?: PassengersCount): Promise<RouteFlightsResponse> {
|
||||
return $fetch(`${BASE_URL}/route-flights`, {
|
||||
method: 'POST',
|
||||
headers: HEADERS,
|
||||
body: { from, to, locale, passengersCount }
|
||||
})
|
||||
}
|
||||
|
||||
// --- API: Multi-City Inspirations ---
|
||||
|
||||
export interface MultiCityInspirationItem {
|
||||
from: string
|
||||
stops: string[]
|
||||
minPrice: number
|
||||
}
|
||||
|
||||
export interface MultiCityInspirationsResponse {
|
||||
currency: { name: string, code: string, symbol: string }
|
||||
items: MultiCityInspirationItem[]
|
||||
}
|
||||
|
||||
export async function getMultiCityInspirations(startLocationsCodes: string[], locale: string = 'en', take: number = 20): Promise<MultiCityInspirationsResponse> {
|
||||
return $fetch(`${BASE_URL}/inspirations/search-multi-city`, {
|
||||
method: 'POST',
|
||||
headers: HEADERS,
|
||||
body: { startLocationsCodes, locale, take }
|
||||
})
|
||||
}
|
||||
|
||||
// --- API: Locations ---
|
||||
|
||||
export interface Airport {
|
||||
id: string
|
||||
iata: string
|
||||
icao: string
|
||||
nameEng: string
|
||||
lat: number
|
||||
lon: number
|
||||
cityId: string
|
||||
timeZone: string
|
||||
timeZoneOffset: string
|
||||
}
|
||||
|
||||
export interface City {
|
||||
id: string
|
||||
name: string
|
||||
nameEng: string
|
||||
countryId: string
|
||||
lat: number
|
||||
lon: number
|
||||
}
|
||||
|
||||
export interface LocationCountry {
|
||||
id: string
|
||||
name: string
|
||||
nameEng: string
|
||||
isoCode2: string
|
||||
}
|
||||
|
||||
export interface LocationsResponse {
|
||||
contractVersion: number
|
||||
locale: string
|
||||
airports: Airport[]
|
||||
cities: City[]
|
||||
countries: LocationCountry[]
|
||||
}
|
||||
|
||||
export async function getLocations(): Promise<LocationsResponse> {
|
||||
return $fetch(`${BASE_URL}/locations`, {
|
||||
method: 'GET',
|
||||
headers: { 'User-Agent': USER_AGENT }
|
||||
})
|
||||
}
|
||||
|
||||
// --- API: Route Data ---
|
||||
|
||||
export async function getRouteData(departureCode: string, arrivalCode: string): Promise<any> {
|
||||
return $fetch(`${BASE_URL}/route-data`, {
|
||||
method: 'GET',
|
||||
headers: { 'User-Agent': USER_AGENT },
|
||||
query: { departureCode, arrivalCode }
|
||||
})
|
||||
}
|
||||
|
||||
// --- API: Countries ---
|
||||
|
||||
export async function getCountries(): Promise<{ countries: Country[] }> {
|
||||
return $fetch(`${BASE_URL}/countries`, {
|
||||
method: 'GET',
|
||||
headers: { 'User-Agent': USER_AGENT }
|
||||
})
|
||||
}
|
||||
|
||||
// --- API: Booking ---
|
||||
|
||||
export async function getBookingInfo(bookingToken: string, passengersCount: PassengersCount): Promise<any> {
|
||||
return $fetch(`${BASE_URL}/booking/info`, {
|
||||
method: 'POST',
|
||||
headers: HEADERS,
|
||||
body: { bookingToken, passengersCount }
|
||||
})
|
||||
}
|
||||
|
||||
export async function initBooking(bookingToken: string, passengersCount: PassengersCount): Promise<any> {
|
||||
return $fetch(`${BASE_URL}/booking/init`, {
|
||||
method: 'POST',
|
||||
headers: HEADERS,
|
||||
body: { bookingToken, passengersCount }
|
||||
})
|
||||
}
|
||||
16
server/utils/search-hash.ts
Normal file
16
server/utils/search-hash.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { hash as ohash } from 'ohash'
|
||||
|
||||
/**
|
||||
* Genera un hash determinista de los parametros de busqueda.
|
||||
* Normaliza el objeto: ordena keys recursivamente y elimina campos
|
||||
* irrelevantes (_poll, maxResults) para que busquedas equivalentes
|
||||
* produzcan el mismo hash.
|
||||
*/
|
||||
export function computeSearchHash(params: Record<string, unknown>): string {
|
||||
const normalized = { ...params }
|
||||
// Campos que no afectan los resultados de la busqueda
|
||||
delete normalized._poll
|
||||
delete normalized.maxResults
|
||||
|
||||
return ohash(normalized)
|
||||
}
|
||||
25
server/utils/supabase-admin.ts
Normal file
25
server/utils/supabase-admin.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
let _client: SupabaseClient | null = null
|
||||
|
||||
/**
|
||||
* Cliente Supabase con service_role para uso fuera de event handlers (ej: worker plugin).
|
||||
* Bypasses RLS — usar solo en contextos server-side confiables.
|
||||
*/
|
||||
export function getSupabaseAdmin(): SupabaseClient {
|
||||
if (_client) return _client
|
||||
|
||||
const url = process.env.SUPABASE_URL
|
||||
const key = process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
|
||||
if (!url || !key) {
|
||||
throw new Error('SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY not configured')
|
||||
}
|
||||
|
||||
_client = createClient(url, key, {
|
||||
auth: { autoRefreshToken: false, persistSession: false }
|
||||
})
|
||||
|
||||
return _client
|
||||
}
|
||||
62
server/utils/wikidata.ts
Normal file
62
server/utils/wikidata.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
const SPARQL_ENDPOINT = 'https://query.wikidata.org/sparql'
|
||||
|
||||
export interface WikidataAirline {
|
||||
iata: string
|
||||
icao: string | null
|
||||
name: string
|
||||
logoUrl: string | null
|
||||
website: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all airlines from Wikidata via SPARQL.
|
||||
* Returns deduplicated list by IATA code (first logo wins).
|
||||
*/
|
||||
export async function getAirlinesFromWikidata(): Promise<WikidataAirline[]> {
|
||||
const query = `
|
||||
SELECT ?airlineLabel ?iata ?icao ?logo ?website WHERE {
|
||||
?airline wdt:P31 wd:Q46970.
|
||||
?airline wdt:P229 ?iata.
|
||||
OPTIONAL { ?airline wdt:P230 ?icao. }
|
||||
OPTIONAL { ?airline wdt:P154 ?logo. }
|
||||
OPTIONAL { ?airline wdt:P856 ?website. }
|
||||
SERVICE wikibase:label { bd:serviceParam wikibase:language "es,en". }
|
||||
}
|
||||
`
|
||||
|
||||
const data = await $fetch<{ results: { bindings: Record<string, { value: string }>[] } }>(
|
||||
SPARQL_ENDPOINT,
|
||||
{
|
||||
query: { query, format: 'json' },
|
||||
headers: {
|
||||
'Accept': 'application/sparql-results+json',
|
||||
'User-Agent': 'Vuelato/1.0 (https://github.com/vuelato; contact@vuelato.com)'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Deduplicate by IATA code (keep first entry with logo if available)
|
||||
const map = new Map<string, WikidataAirline>()
|
||||
|
||||
for (const row of data.results.bindings) {
|
||||
const iata = row.iata?.value
|
||||
if (!iata) continue
|
||||
|
||||
const existing = map.get(iata)
|
||||
const logoUrl = row.logo?.value || null
|
||||
const website = row.website?.value || null
|
||||
|
||||
// Keep entry with logo/website over entry without
|
||||
if (!existing || (!existing.logoUrl && logoUrl) || (!existing.website && website)) {
|
||||
map.set(iata, {
|
||||
iata,
|
||||
icao: row.icao?.value || existing?.icao || null,
|
||||
name: row.airlineLabel?.value || existing?.name || '',
|
||||
logoUrl: logoUrl || existing?.logoUrl || null,
|
||||
website: website || existing?.website || null
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(map.values())
|
||||
}
|
||||
Reference in New Issue
Block a user