Initial commit: Vuelato - buscador de vuelos
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:
Alejandro Martinez
2026-04-10 23:37:06 +02:00
commit b8906efc80
122 changed files with 37809 additions and 0 deletions

View 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
View File

@@ -0,0 +1,4 @@
export default defineEventHandler(async (event) => {
const { bookingToken, local, passengersCount } = await readBody(event)
return checkTrip(bookingToken, local, passengersCount)
})

View File

@@ -0,0 +1,3 @@
export default defineCachedEventHandler(async () => {
return getCountries()
}, { maxAge: 60 * 60 * 24 }) // cache 24h

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

View File

@@ -0,0 +1,4 @@
export default defineEventHandler(async (event) => {
const { bookingToken, local, passengersCount } = await readBody(event)
return getTripDetail(bookingToken, local, passengersCount)
})

View 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

View 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')
})

View File

@@ -0,0 +1,3 @@
export default defineCachedEventHandler(async () => {
return getLocations()
}, { maxAge: 60 * 60 * 24 }) // cache 24h

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

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

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

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

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

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

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

View 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 || []
})

View 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 || []
})

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

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

View File

@@ -0,0 +1,4 @@
export default defineEventHandler(async (event) => {
const body = await readBody(event)
return searchWeekendTrips(body)
})