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)
|
||||
})
|
||||
Reference in New Issue
Block a user