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

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

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

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