← back to Handbag Authentication

server.js

840 lines

require('dotenv').config();
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const path = require('path');
const Database = require('./db/schema');
const PriceAnalyzer = require('./ai/analyzer');
const SlackNotifier = require('./ai/slack-notifier');
const { Parser } = require('json2csv');

const app = express();
// Security headers via helmet (added 2026-05-04 overnight YOLO loop)
app.use(helmet({ contentSecurityPolicy: false }));
const PORT = process.env.PORT || 7989;
const multer = require('multer');
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } });

app.use(cors());
app.use(express.json());

// 404-guard: block requests to backup / editor / patch artifacts (standing rule)
app.use((req, res, next) => {
  if (/\.(bak|orig|rej|swp|swo)(\.|$)|\.pre-|~$/i.test(req.path)) {
    return res.status(404).send('Not Found');
  }
  next();
});

app.use(express.static(path.join(__dirname, 'public')));
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));

const db = new Database(process.env.DATABASE_PATH);
const slackNotifier = new SlackNotifier();
const MobileAPI = require('./api/mobile-api');
const mobileAPI = new MobileAPI();

// Home page
app.get('/', (req, res) => {
  res.render('index');
});

// Product detail page
app.get('/product/:id', async (req, res) => {
  const productId = req.params.id;

  db.db.get(`
    SELECT l.*, da.*
    FROM listings l
    LEFT JOIN deal_analysis da ON l.id = da.listing_id
    WHERE l.id = ? OR l.external_id = ?
  `, [productId, productId], async (err, product) => {
    if (err || !product) {
      console.error('Error fetching product:', err);
      return res.status(404).render('error', { message: 'Product not found' });
    }

    // Generate cross-listing data
    const CrossListingGenerator = require('./api/cross-listing-generator');
    const generator = new CrossListingGenerator();

    const sku = generator.generateSKU(product.brand, product.model, product.color, product.size);
    const platformProfits = generator.calculatePlatformProfits(product.price_usd, product.avg_us_price);
    const crossListingLinks = generator.generateCrossListingLinks(product, sku, product.avg_us_price);
    const platformGuide = generator.generatePlatformGuide();
    const resaleGuide = generator.generateUsResaleGuide();

    res.render('product', {
      product,
      sku,
      platformProfits,
      crossListingLinks,
      platformGuide,
      resaleGuide
    });
  });
});

// Price Tracker page
app.get('/price-tracker', (req, res) => {
  res.render('price-tracker');
});

// Mobile Camera Analysis page
app.get('/camera', (req, res) => {
  res.render('camera');
});

// Business Startup Guide page
app.get('/business-guide', (req, res) => {
  res.render('business-guide');
});

// API: Get all listings with filters
app.get('/api/listings', (req, res) => {
  const filters = {
    brand: req.query.brand,
    model: req.query.model, // NEW: Signature bag filter
    minPrice: req.query.minPrice ? parseFloat(req.query.minPrice) : null,
    maxPrice: req.query.maxPrice ? parseFloat(req.query.maxPrice) : null,
    listing_type: req.query.type,
    dealOnly: req.query.dealOnly === 'true',
    minDealPercentage: req.query.minDeal ? parseFloat(req.query.minDeal) : null,
    sort: req.query.sort || 'deal',
    limit: req.query.limit ? parseInt(req.query.limit) : 100
  };

  db.getListings(filters, (err, listings) => {
    if (err) {
      console.error('Error fetching listings:', err);
      return res.status(500).json({ error: 'Database error' });
    }

    res.json({
      count: listings.length,
      listings: listings
    });
  });
});

// API: Get best deals
app.get('/api/deals', (req, res) => {
  const minDeal = parseFloat(req.query.minDeal) || 20;

  db.getListings({ minDealPercentage: minDeal, dealOnly: true, sort: 'deal', limit: 50 }, (err, listings) => {
    if (err) {
      return res.status(500).json({ error: 'Database error' });
    }

    res.json({
      count: listings.length,
      deals: listings
    });
  });
});

// API: Get Birkin listings for price chart
app.get('/api/arbitrage/birkins', (req, res) => {
  db.db.all(`
    SELECT *
    FROM listings
    WHERE (title LIKE '%Birkin%' OR title LIKE '%バーキン%')
    AND is_active = 1
    ORDER BY price_usd ASC
  `, (err, listings) => {
    if (err) {
      console.error('Error fetching Birkins:', err);
      return res.status(500).json({ error: 'Database error' });
    }

    res.json({
      count: listings.length,
      listings: listings
    });
  });
});

// API: Upload and analyze handbag image
app.post('/api/analyze/image', upload.single('image'), async (req, res) => {
  try {
    if (!req.file) {
      return res.status(400).json({ error: 'No image provided' });
    }

    console.log('📸 Image uploaded:', req.file.originalname, req.file.size, 'bytes');

    const ImageSearchEngine = require('./api/image-search');
    const searchEngine = new ImageSearchEngine();

    // Search all databases with the uploaded image
    const results = await searchEngine.searchByImage(req.file.buffer);

    console.log('✅ Search complete:', results.matches.total, 'matches found');

    res.json({
      success: true,
      analysis: results.analysis,
      matches: results.matches,
      visuallySimilar: results.visuallySimilar,
      priceComparisons: results.priceComparisons
    });

  } catch (error) {
    console.error('Image analysis error:', error);
    res.status(500).json({
      success: false,
      error: 'Image analysis failed',
      message: error.message
    });
  }
});

// API: Get statistics
app.get('/api/stats', (req, res) => {
  const queries = [
    'SELECT COUNT(*) as total FROM listings WHERE is_active = 1',
    'SELECT COUNT(*) as deals FROM deal_analysis WHERE is_deal = 1',
    'SELECT AVG(deal_percentage) as avg_deal FROM deal_analysis WHERE is_deal = 1',
    'SELECT MAX(deal_percentage) as max_deal FROM deal_analysis',
    'SELECT COUNT(DISTINCT brand) as brands FROM listings WHERE is_active = 1'
  ];

  Promise.all(queries.map(query =>
    new Promise((resolve, reject) => {
      db.db.get(query, (err, row) => {
        if (err) reject(err);
        else resolve(row);
      });
    })
  )).then(results => {
    res.json({
      total_listings: results[0].total,
      total_deals: results[1].deals,
      avg_deal_percentage: Math.round(results[2].avg_deal * 10) / 10,
      best_deal_percentage: Math.round(results[3].max_deal * 10) / 10,
      brands: results[4].brands
    });
  }).catch(err => {
    console.error('Stats error:', err);
    res.status(500).json({ error: 'Database error' });
  });
});

// API: Export to CSV
app.get('/api/export/csv', (req, res) => {
  const filters = {
    dealOnly: req.query.dealOnly === 'true',
    minDealPercentage: req.query.minDeal ? parseFloat(req.query.minDeal) : null,
    sort: 'deal'
  };

  db.getListings(filters, (err, listings) => {
    if (err) {
      return res.status(500).json({ error: 'Database error' });
    }

    const fields = [
      'brand',
      'title',
      'model',
      'size',
      'color',
      'material',
      'price_jpy',
      'price_usd',
      'avg_us_price',
      'retail_msrp',
      'appreciation_percent',
      'deal_percentage',
      'condition',
      'listing_type',
      'source',
      'product_url',
      'is_deal',
      'confidence_score',
      'ai_notes'
    ];

    try {
      const parser = new Parser({ fields });
      const csv = parser.parse(listings);

      res.header('Content-Type', 'text/csv');
      res.attachment(`luxarb-prime-${Date.now()}.csv`);
      res.send(csv);
    } catch (error) {
      console.error('CSV export error:', error);
      res.status(500).json({ error: 'Export failed' });
    }
  });
});

// API: Export Hermès database with retail tracking
app.get('/api/export/hermes-database', async (req, res) => {
  try {
    const DatabaseExporter = require('./api/database-export');
    const exporter = new DatabaseExporter();
    const data = await exporter.exportHermesDatabase();

    res.json({
      success: true,
      count: data.length,
      data: data
    });
  } catch (error) {
    console.error('Hermès export error:', error);
    res.status(500).json({ error: 'Export failed' });
  }
});

// API: Brand statistics
app.get('/api/stats/brands', async (req, res) => {
  try {
    const DatabaseExporter = require('./api/database-export');
    const exporter = new DatabaseExporter();
    const stats = await exporter.getBrandStats();

    res.json({
      success: true,
      brands: stats
    });
  } catch (error) {
    console.error('Brand stats error:', error);
    res.status(500).json({ error: 'Failed to get stats' });
  }
});

// API: Get sold comps for a product
app.get('/api/sold-comps/:brand/:model', async (req, res) => {
  const { brand, model } = req.params;

  try {
    const SoldCompsAggregator = require('./api/sold-comps-aggregator');
    const aggregator = new SoldCompsAggregator();

    const data = await aggregator.aggregateAllSoldComps(brand, model);

    res.json({
      success: true,
      brand,
      model,
      ...data
    });
  } catch (error) {
    console.error('Error fetching sold comps:', error);
    res.status(500).json({
      success: false,
      error: 'Failed to fetch sold comps'
    });
  }
});

// API: Extract full Yahoo Japan data
app.post('/api/extract-yahoo-data', async (req, res) => {
  const { yahooUrl } = req.body;

  if (!yahooUrl) {
    return res.status(400).json({ success: false, error: 'Missing yahooUrl' });
  }

  try {
    const YahooDataExtractor = require('./api/yahoo-data-extractor');
    const extractor = new YahooDataExtractor();

    const details = await extractor.extractFullDetails(yahooUrl);

    if (!details) {
      return res.status(500).json({ success: false, error: 'Extraction failed' });
    }

    // Translate condition
    const conditionEn = await extractor.translateCondition(details.condition);
    details.conditionEnglish = conditionEn;

    await extractor.close();

    res.json({
      success: true,
      data: details
    });

  } catch (error) {
    console.error('Yahoo extraction error:', error);
    res.status(500).json({
      success: false,
      error: 'Extraction failed'
    });
  }
});

// API: Trigger manual crawl
app.post('/api/crawl', async (req, res) => {
  res.json({ message: 'Crawl started in background' });

  const CrawlerOrchestrator = require('./crawler/index');
  const crawler = new CrawlerOrchestrator();

  try {
    await crawler.crawlAll();

    // Run AI analysis after crawl
    const analyzer = new PriceAnalyzer();
    await analyzer.analyzeListings();

  } catch (error) {
    console.error('Manual crawl error:', error);
  }
});

// API: Get single listing details
app.get('/api/listing/:id', (req, res) => {
  const query = `
    SELECT
      l.*,
      d.*,
      GROUP_CONCAT(
        json_object(
          'source', c.us_source,
          'price', c.us_price_usd,
          'url', c.us_url,
          'condition', c.us_condition
        )
      ) as us_comparisons
    FROM listings l
    LEFT JOIN deal_analysis d ON l.id = d.listing_id
    LEFT JOIN us_comparisons c ON l.id = c.listing_id
    WHERE l.id = ?
    GROUP BY l.id
  `;

  db.db.get(query, [req.params.id], (err, listing) => {
    if (err) {
      return res.status(500).json({ error: 'Database error' });
    }

    if (!listing) {
      return res.status(404).json({ error: 'Listing not found' });
    }

    // Parse US comparisons
    if (listing.us_comparisons) {
      try {
        listing.us_comparisons = listing.us_comparisons
          .split(',')
          .map(c => JSON.parse(c));
      } catch (e) {
        listing.us_comparisons = [];
      }
    }

    res.json(listing);
  });
});

// Start server
app.listen(PORT, '0.0.0.0', () => {
  console.log(`\n🚀 Japanese Handbag Arbitrage Platform`);
  console.log(`📍 Server running on http://0.0.0.0:${PORT}`);
  console.log(`🔥 Monitoring deals >= ${process.env.DEAL_THRESHOLD || 20}% below market\n`);
});

// API: Image/Video Analysis with Google Gemini Vision
app.post('/api/analyze-image', async (req, res) => {
  try {
    const { image, files } = req.body;

    if (!image && (!files || files.length === 0)) {
      return res.status(400).json({ success: false, error: 'No image or files provided' });
    }

    const { GoogleGenerativeAI } = require('@google/generative-ai');
    const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY);
    const RetailPriceDatabase = require('./api/retail-price-database');
    const retailDB = new RetailPriceDatabase();

    // Support both single image (legacy) and multiple files (new)
    const filesToAnalyze = files || [{ data: image, type: 'image/jpeg' }];

    // Convert files to Gemini format
    const parts = [];

    // Add text prompt first
    parts.push({
      text: `Analyze this luxury handbag from ${filesToAnalyze.length} image(s)/video(s). Provide detailed authentication analysis.

Identify:
1. Brand (exact name)
2. Model/Style (e.g., "Birkin 30", "Classic Flap Medium")
3. Material (e.g., "Togo leather", "Lambskin", "Canvas")
4. Color (exact color name)
5. Hardware (e.g., "Gold", "Silver", "Palladium")
6. Condition: Excellent/Very Good/Good/Fair/Poor
7. Hardware condition
8. Visible issues/flaws
9. Authenticity confidence: High/Medium/Low
10. Specific authenticity markers you observe (stitching, stamps, hardware, etc.)
11. Red flags if any
12. Current market value range in USD
13. Best platforms to sell (eBay, Poshmark, Fashionphile, etc.)
14. Market demand level (High/Medium/Low)
15. Investment rating (Excellent/Good/Fair/Poor)

Return ONLY valid JSON with these exact keys:
{
  "brand": "",
  "model": "",
  "style": "",
  "material": "",
  "color": "",
  "hardware": "",
  "condition": "",
  "hardware_condition": "",
  "issues": "",
  "authenticity_confidence": "",
  "authenticity_markers": "",
  "red_flags": "",
  "estimated_value_low": 0,
  "estimated_value_high": 0,
  "current_market_value": 0,
  "best_platforms": "",
  "market_notes": "",
  "investment_rating": "",
  "demand_level": ""
}`
    });

    // Add all images/videos
    for (const file of filesToAnalyze) {
      const base64Data = file.data.split(',')[1]; // Remove data:image/jpeg;base64, prefix
      const mimeType = file.type || 'image/jpeg';

      parts.push({
        inlineData: {
          data: base64Data,
          mimeType: mimeType
        }
      });
    }

    const model = genAI.getGenerativeModel({ model: 'gemini-1.5-flash' });
    const result = await model.generateContent(parts);
    const response = await result.response;
    let text = response.text();

    // Remove markdown code blocks if present
    text = text.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();

    const analysis = JSON.parse(text);

    // Look up retail MSRP
    const retailInfo = retailDB.findRetailPrice(`${analysis.brand} ${analysis.model}`, analysis.brand);
    if (retailInfo) {
      analysis.retail_msrp = retailInfo.msrp;
    }

    res.json({
      success: true,
      analysis,
      files_analyzed: filesToAnalyze.length
    });

  } catch (error) {
    console.error('Image analysis error:', error);
    res.status(500).json({ success: false, error: 'Analysis failed: ' + error.message });
  }
});

// =====================
// MOBILE API ENDPOINTS
// =====================

// Get handbags with filters (mobile optimized)
app.get('/api/mobile/handbags', async (req, res) => {
  try {
    const options = {
      page: parseInt(req.query.page) || 1,
      limit: parseInt(req.query.limit) || 20,
      category: req.query.category || 'all',
      brand: req.query.brand || null,
      minPrice: req.query.minPrice ? parseFloat(req.query.minPrice) : null,
      maxPrice: req.query.maxPrice ? parseFloat(req.query.maxPrice) : null,
      search: req.query.search || null,
      sortBy: req.query.sortBy || 'recent'
    };

    const result = await mobileAPI.getHandbags(options);
    res.json({ success: true, ...result });
  } catch (error) {
    console.error('Mobile API error:', error);
    res.status(500).json({ success: false, error: error.message });
  }
});

// Get statistics
app.get('/api/mobile/stats', async (req, res) => {
  try {
    const stats = await mobileAPI.getStats();
    res.json({ success: true, stats });
  } catch (error) {
    console.error('Stats API error:', error);
    res.status(500).json({ success: false, error: error.message });
  }
});

// Get brands list
app.get('/api/mobile/brands', async (req, res) => {
  try {
    const brands = await mobileAPI.getBrands();
    res.json({ success: true, brands });
  } catch (error) {
    console.error('Brands API error:', error);
    res.status(500).json({ success: false, error: error.message });
  }
});

// Get product by ID
app.get('/api/mobile/product/:id', async (req, res) => {
  try {
    const product = await mobileAPI.getProductById(req.params.id);
    if (product) {
      res.json({ success: true, product });
    } else {
      res.status(404).json({ success: false, error: 'Product not found' });
    }
  } catch (error) {
    console.error('Product API error:', error);
    res.status(500).json({ success: false, error: error.message });
  }
});

// Search suggestions
app.get('/api/mobile/suggestions', async (req, res) => {
  try {
    const query = req.query.q || '';
    const suggestions = await mobileAPI.getSearchSuggestions(query);
    res.json({ success: true, suggestions });
  } catch (error) {
    console.error('Suggestions API error:', error);
    res.status(500).json({ success: false, error: error.message });
  }
});

// Health check endpoint
app.get('/api/mobile/health', (req, res) => {
  res.json({
    success: true,
    status: 'healthy',
    timestamp: new Date().toISOString(),
    endpoints: {
      handbags: '/api/mobile/handbags',
      stats: '/api/mobile/stats',
      brands: '/api/mobile/brands',
      product: '/api/mobile/product/:id',
      suggestions: '/api/mobile/suggestions'
    }
  });
});

// Price History API endpoints
const PriceHistoryAPI = require('./api/price-history');
const priceHistoryAPI = new PriceHistoryAPI();

// Search API endpoints
const SearchAPI = require('./api/search');
const searchAPI = new SearchAPI();

// Get recent price history
app.get('/api/price-history/recent', async (req, res) => {
  try {
    const { limit = 100, brand, retailer } = req.query;
    const history = await priceHistoryAPI.getRecentHistory(limit, brand, retailer);
    res.json({ 
      success: true, 
      count: history.length,
      last_updated: new Date().toISOString(),
      data: history 
    });
  } catch (error) {
    console.error('Price history error:', error);
    res.status(500).json({ success: false, error: error.message });
  }
});

// Get new listings (added today)
app.get('/api/price-history/new', async (req, res) => {
  try {
    const newListings = await priceHistoryAPI.getNewListings();
    res.json({ 
      success: true, 
      count: newListings.length,
      date: new Date().toISOString().split('T')[0],
      data: newListings 
    });
  } catch (error) {
    console.error('New listings error:', error);
    res.status(500).json({ success: false, error: error.message });
  }
});

// Get price changes
app.get('/api/price-history/changes', async (req, res) => {
  try {
    const { threshold = 100 } = req.query;
    const changes = await priceHistoryAPI.getPriceChanges(threshold);
    res.json({ 
      success: true, 
      count: changes.length,
      threshold: threshold,
      data: changes 
    });
  } catch (error) {
    console.error('Price changes error:', error);
    res.status(500).json({ success: false, error: error.message });
  }
});

// Get price trends
app.get('/api/price-history/trends', async (req, res) => {
  try {
    const { brand, model, days = 30 } = req.query;
    if (!brand) {
      return res.status(400).json({ success: false, error: 'Brand parameter is required' });
    }
    const trends = await priceHistoryAPI.getPriceTrends(brand, model, days);
    res.json({ 
      success: true, 
      brand: brand,
      model: model || 'all',
      days: days,
      data: trends 
    });
  } catch (error) {
    console.error('Price trends error:', error);
    res.status(500).json({ success: false, error: error.message });
  }
});

// Get summary statistics
app.get('/api/price-history/stats', async (req, res) => {
  try {
    const stats = await priceHistoryAPI.getSummaryStats();
    res.json({ 
      success: true,
      last_updated: new Date().toISOString(),
      data: stats 
    });
  } catch (error) {
    console.error('Stats error:', error);
    res.status(500).json({ success: false, error: error.message });
  }
});

// COMPREHENSIVE SEARCH API ENDPOINTS

// Full-text search across all fields
app.get('/api/search', async (req, res) => {
  try {
    const { q, limit = 100, offset = 0 } = req.query;
    if (!q) {
      return res.status(400).json({ success: false, error: 'Query parameter (q) is required' });
    }
    const results = await searchAPI.fullTextSearch(q, { limit, offset });
    res.json({ success: true, ...results });
  } catch (error) {
    console.error('Search error:', error);
    res.status(500).json({ success: false, error: error.message });
  }
});

// Advanced search with filters
app.post('/api/search/advanced', async (req, res) => {
  try {
    const results = await searchAPI.advancedSearch(req.body);
    res.json({ success: true, ...results });
  } catch (error) {
    console.error('Advanced search error:', error);
    res.status(500).json({ success: false, error: error.message });
  }
});

// Search by image or similar items
app.get('/api/search/image', async (req, res) => {
  try {
    const { url, listing_id } = req.query;
    if (!url && !listing_id) {
      return res.status(400).json({ 
        success: false, 
        error: 'Either url or listing_id parameter is required' 
      });
    }
    const results = await searchAPI.searchByImage(url, listing_id);
    res.json({ success: true, ...results });
  } catch (error) {
    console.error('Image search error:', error);
    res.status(500).json({ success: false, error: error.message });
  }
});

// Get search suggestions
app.get('/api/search/suggestions', async (req, res) => {
  try {
    const { q, limit = 10 } = req.query;
    if (!q) {
      return res.status(400).json({ success: false, error: 'Query parameter (q) is required' });
    }
    const suggestions = await searchAPI.getSuggestions(q, limit);
    res.json({ success: true, suggestions });
  } catch (error) {
    console.error('Suggestions error:', error);
    res.status(500).json({ success: false, error: error.message });
  }
});

// Get popular searches
app.get('/api/search/popular', async (req, res) => {
  try {
    const { days = 7, limit = 20 } = req.query;
    const searches = await searchAPI.getPopularSearches(days, limit);
    res.json({ success: true, searches });
  } catch (error) {
    console.error('Popular searches error:', error);
    res.status(500).json({ success: false, error: error.message });
  }
});

// Search by specific field
app.get('/api/search/field/:field', async (req, res) => {
  try {
    const { field } = req.params;
    const { value, exact = false } = req.query;
    if (!value) {
      return res.status(400).json({ success: false, error: 'Value parameter is required' });
    }
    const results = await searchAPI.searchByField(field, value, exact === 'true');
    res.json({ success: true, ...results });
  } catch (error) {
    console.error('Field search error:', error);
    res.status(500).json({ success: false, error: error.message });
  }
});

// Get unique values for a field (for filter dropdowns)
app.get('/api/search/values/:field', async (req, res) => {
  try {
    const { field } = req.params;
    const values = await searchAPI.getFieldValues(field);
    res.json({ success: true, field, values });
  } catch (error) {
    console.error('Field values error:', error);
    res.status(500).json({ success: false, error: error.message });
  }
});

// Graceful shutdown
process.on('SIGINT', () => {
  console.log('\nShutting down gracefully...');
  db.close();
  process.exit(0);
});