← back to Handbag Auth Nextjs
src/app/api/ebay-sold/route.ts
215 lines
import { NextRequest, NextResponse } from 'next/server'
import { rateLimit, getClientIp } from '@/lib/rateLimit'
import { addSecurityHeaders } from '@/lib/securityHeaders'
import { validateInput, ebaySoldSchema } from '@/lib/validation'
/**
* eBay Sold Listings API
* Fetch real sales history for handbag valuation
*/
const EBAY_FINDING_API = 'https://svcs.ebay.com/services/search/FindingService/v1'
const EBAY_APP_ID = process.env.EBAY_APP_ID || ''
interface EbaySoldListing {
item_id: string
title: string
price_usd: number
currency: string
condition: string
end_time: string
location: string
url: string
image_url: string
}
export async function GET(request: NextRequest) {
try {
// Rate limiting - stricter for external API calls
const clientIp = getClientIp(request)
const rateLimitResult = rateLimit(clientIp, {
windowMs: 60 * 1000,
maxRequests: 30, // Lower limit for eBay API calls
})
if (!rateLimitResult.allowed) {
const response = NextResponse.json(
{ success: false, error: 'Rate limit exceeded' },
{ status: 429 }
)
response.headers.set('Retry-After', String(Math.ceil((rateLimitResult.resetTime - Date.now()) / 1000)))
addSecurityHeaders(response)
return response
}
// Input validation
const searchParams = request.nextUrl.searchParams
const validation = validateInput(ebaySoldSchema, {
query: searchParams.get('query'),
brand: searchParams.get('brand'),
model: searchParams.get('model'),
daysBack: searchParams.get('daysBack'),
maxResults: searchParams.get('maxResults'),
})
if (!validation.success) {
const response = NextResponse.json(
{ success: false, error: 'Invalid input parameters' },
{ status: 400 }
)
addSecurityHeaders(response)
return response
}
const { query, brand, model, daysBack, maxResults } = validation.data
if (!query && !brand) {
const response = NextResponse.json(
{ success: false, error: 'query or brand parameter required' },
{ status: 400 }
)
addSecurityHeaders(response)
return response
}
if (!EBAY_APP_ID) {
const response = NextResponse.json(
{ success: false, error: 'Service temporarily unavailable' }, // Don't expose missing config
{ status: 503 }
)
addSecurityHeaders(response)
return response
}
// Build search query
let searchQuery = query || `${brand} ${model || ''}`.trim()
// Calculate date range
const endDate = new Date()
const startDate = new Date()
startDate.setDate(startDate.getDate() - daysBack)
// Build API request
const params = new URLSearchParams({
'OPERATION-NAME': 'findCompletedItems',
'SERVICE-VERSION': '1.0.0',
'SECURITY-APPNAME': EBAY_APP_ID,
'RESPONSE-DATA-FORMAT': 'JSON',
'REST-PAYLOAD': '',
'keywords': searchQuery,
'paginationInput.entriesPerPage': Math.min(maxResults, 100).toString(),
'itemFilter(0).name': 'SoldItemsOnly',
'itemFilter(0).value': 'true',
'itemFilter(1).name': 'EndTimeFrom',
'itemFilter(1).value': startDate.toISOString(),
'itemFilter(2).name': 'EndTimeTo',
'itemFilter(2).value': endDate.toISOString(),
})
// Make request to eBay
const ebayResponse = await fetch(`${EBAY_FINDING_API}?${params}`, {
method: 'GET',
headers: {
'Accept': 'application/json',
},
})
if (!ebayResponse.ok) {
throw new Error(`eBay API error: ${ebayResponse.statusText}`)
}
const data = await ebayResponse.json()
// Parse results
const soldListings: EbaySoldListing[] = []
try {
const searchResult = data.findCompletedItemsResponse[0].searchResult[0]
if (searchResult['@count'] === '0') {
const emptyResponse = NextResponse.json({
success: true,
query: searchQuery,
count: 0,
sold_listings: [],
statistics: {
count: 0,
avg_price: 0,
median_price: 0,
min_price: 0,
max_price: 0,
},
})
addSecurityHeaders(emptyResponse)
return emptyResponse
}
const items = searchResult.item
for (const item of items) {
// Only include sold items (not just ended)
const sellingStatus = item.sellingStatus[0]
const sellingState = sellingStatus.sellingState[0]
if (sellingState !== 'EndedWithSales') {
continue
}
soldListings.push({
item_id: item.itemId[0],
title: item.title[0],
price_usd: parseFloat(sellingStatus.convertedCurrentPrice[0].__value__),
currency: sellingStatus.convertedCurrentPrice[0]['@currencyId'],
condition: item.condition?.[0]?.conditionDisplayName?.[0] || 'Unknown',
end_time: item.listingInfo[0].endTime[0],
location: item.location?.[0] || '',
url: item.viewItemURL[0],
image_url: item.galleryURL?.[0] || '',
})
}
} catch (error) {
console.error('Error parsing eBay response:', error)
}
// Calculate statistics
const prices = soldListings.map(l => l.price_usd)
const sortedPrices = prices.sort((a, b) => a - b)
const medianIdx = Math.floor(sortedPrices.length / 2)
const median = sortedPrices.length % 2 === 1
? sortedPrices[medianIdx]
: (sortedPrices[medianIdx - 1] + sortedPrices[medianIdx]) / 2
const statistics = {
count: soldListings.length,
avg_price: prices.length > 0 ? prices.reduce((a, b) => a + b, 0) / prices.length : 0,
median_price: median || 0,
min_price: prices.length > 0 ? Math.min(...prices) : 0,
max_price: prices.length > 0 ? Math.max(...prices) : 0,
}
const successResponse = NextResponse.json({
success: true,
query: searchQuery,
count: soldListings.length,
sold_listings: soldListings,
statistics,
date_range: {
from: startDate.toISOString(),
to: endDate.toISOString(),
days: daysBack,
},
})
addSecurityHeaders(successResponse)
return successResponse
} catch (error) {
// Log error internally but don't expose details
console.error('eBay sold listings API error:', error)
const errorResponse = NextResponse.json(
{ success: false, error: 'Failed to fetch sold listings' }, // Generic error message
{ status: 500 }
)
addSecurityHeaders(errorResponse)
return errorResponse
}
}