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